Subida del módulo y tema de PrestaShop

This commit is contained in:
Kaloyan
2026-04-09 18:31:51 +02:00
parent 12c253296f
commit 16b3ff9424
39262 changed files with 7418797 additions and 0 deletions

10
vendor/.htaccess vendored Normal file
View File

@@ -0,0 +1,10 @@
# Apache 2.2
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
# Apache 2.4
<IfModule mod_authz_core.c>
Require all denied
</IfModule>

21
vendor/api-platform/core/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT license
Copyright (c) 2015-present Kévin Dunglas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

2
vendor/api-platform/core/codecov.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
ignore:
- "src/**/Tests/"

1
vendor/api-platform/core/pmu.baseline vendored Normal file
View File

@@ -0,0 +1 @@
Class "ApiPlatform\Serializer\SerializerContextBuilder" uses "ApiPlatform\Doctrine\Orm\State\Options" but it is not declared as dependency.

View File

@@ -0,0 +1,60 @@
<?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\Action;
use ApiPlatform\Api\Entrypoint;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Generates the API entrypoint.
*
* @deprecated use ApiPlatform\Documentation\Action\EntrypointAction
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
final class EntrypointAction
{
public function __construct(
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
private readonly ?ProviderInterface $provider = null,
private readonly ?ProcessorInterface $processor = null,
private readonly array $documentationFormats = [],
) {
}
/**
* @return Entrypoint|Response
*/
public function __invoke(?Request $request = null)
{
if ($this->provider && $this->processor) {
$context = ['request' => $request];
$operation = new Get(outputFormats: $this->documentationFormats, read: true, serialize: true, class: Entrypoint::class, provider: fn () => new Entrypoint($this->resourceNameCollectionFactory->create()));
$body = $this->provider->provide($operation, [], $context);
// see https://github.com/api-platform/core/issues/5845#issuecomment-1732400657
if ($request && ($apiOperation = $request->attributes->get('_api_operation'))) {
$operation = $apiOperation;
}
return $this->processor->process($body, $operation, [], $context);
}
return new Entrypoint($this->resourceNameCollectionFactory->create());
}
}

View File

@@ -0,0 +1,117 @@
<?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\Action;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\Util\OperationRequestInitiatorTrait;
use ApiPlatform\Symfony\Util\RequestAttributesExtractor;
use ApiPlatform\Symfony\Validator\Exception\ConstraintViolationListAwareExceptionInterface;
use ApiPlatform\Util\ErrorFormatGuesser;
use ApiPlatform\Validator\Exception\ConstraintViolationListAwareExceptionInterface as ApiPlatformConstraintViolationListAwareExceptionInterface;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Renders a normalized exception for a given see [FlattenException](https://github.com/symfony/symfony/blob/6.3/src/Symfony/Component/ErrorHandler/Exception/FlattenException.php).
*
* @author Baptiste Meyer <baptiste.meyer@gmail.com>
* @author Kévin Dunglas <dunglas@gmail.com>
*
* @deprecated since API Platform 3 and Error resource is used {@see ApiPlatform\Symfony\EventListener\ErrorListener}
*/
final class ExceptionAction
{
use OperationRequestInitiatorTrait;
/**
* @param array $errorFormats A list of enabled error formats
* @param array $exceptionToStatus A list of exceptions mapped to their HTTP status code
*/
public function __construct(private readonly SerializerInterface $serializer, private readonly array $errorFormats, private readonly array $exceptionToStatus = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null)
{
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
}
/**
* Converts an exception to a JSON response.
*/
public function __invoke(FlattenException $exception, Request $request): Response
{
$operation = $this->initializeOperation($request);
$exceptionClass = $exception->getClass();
$statusCode = $exception->getStatusCode();
$exceptionToStatus = array_merge(
$this->exceptionToStatus,
$operation ? $operation->getExceptionToStatus() ?? [] : $this->getOperationExceptionToStatus($request)
);
foreach ($exceptionToStatus as $class => $status) {
if (is_a($exceptionClass, $class, true)) {
$statusCode = $status;
break;
}
}
$headers = $exception->getHeaders();
$format = ErrorFormatGuesser::guessErrorFormat($request, $this->errorFormats);
$headers['Content-Type'] = \sprintf('%s; charset=utf-8', $format['value'][0]);
$headers['X-Content-Type-Options'] = 'nosniff';
$headers['X-Frame-Options'] = 'deny';
$context = ['statusCode' => $statusCode, 'rfc_7807_compliant_errors' => $operation?->getExtraProperties()['rfc_7807_compliant_errors'] ?? false];
$error = $request->attributes->get('exception') ?? $exception;
if ($error instanceof ConstraintViolationListAwareExceptionInterface || $error instanceof ApiPlatformConstraintViolationListAwareExceptionInterface) {
$error = $error->getConstraintViolationList();
} elseif (method_exists($error, 'getViolations') && $error->getViolations() instanceof ConstraintViolationListInterface) {
$error = $error->getViolations();
} else {
$error = $exception;
}
$serializerFormat = $format['key'];
if ('json' === $serializerFormat && 'application/problem+json' === $format['value'][0]) {
$serializerFormat = 'jsonproblem';
}
return new Response($this->serializer->serialize($error, $serializerFormat, $context), $statusCode, $headers);
}
private function getOperationExceptionToStatus(Request $request): array
{
$attributes = RequestAttributesExtractor::extractAttributes($request);
if ([] === $attributes) {
return [];
}
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($attributes['resource_class']);
/** @var HttpOperation $operation */
$operation = $resourceMetadataCollection->getOperation($attributes['operation_name'] ?? null);
$exceptionToStatus = [$operation->getExceptionToStatus() ?: []];
foreach ($resourceMetadataCollection as $resourceMetadata) {
/* @var ApiResource $resourceMetadata */
$exceptionToStatus[] = $resourceMetadata->getExceptionToStatus() ?: [];
}
return array_merge(...$exceptionToStatus);
}
}

View File

@@ -0,0 +1,35 @@
<?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\Action;
use ApiPlatform\Exception\NotExposedHttpException;
use Symfony\Component\HttpFoundation\Request;
/**
* An action which always returns HTTP 404 Not Found with an explanation for why the operation is not exposed.
*
* @deprecated use ApiPlatform\Symfony\Action\NotExposedAction
*/
final class NotExposedAction
{
public function __invoke(Request $request): never
{
$message = 'This route does not aim to be called.';
if ('api_genid' === $request->attributes->get('_route')) {
$message = 'This route is not exposed on purpose. It generates an IRI for a collection resource without identifier nor item operation.';
}
throw new NotExposedHttpException($message);
}
}

View File

@@ -0,0 +1,29 @@
<?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\Action;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* An action which always returns HTTP 404 Not Found. Useful for disabling an operation.
*
* @deprecated use ApiPlatform\Symfony\Action\NotFoundAction
*/
final class NotFoundAction
{
public function __invoke(): void
{
throw new NotFoundHttpException();
}
}

View File

@@ -0,0 +1,34 @@
<?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\Action;
/**
* Placeholder returning the data passed in parameter.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*
* @deprecated use ApiPlatform\Symfony\Action\PlaceholderAction
*/
final class PlaceholderAction
{
/**
* @param object $data
*
* @return object
*/
public function __invoke($data)
{
return $data;
}
}

View File

@@ -0,0 +1,65 @@
<?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\Api;
/**
* Normalizes a composite identifier.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*
* @deprecated
*/
final class CompositeIdentifierParser
{
public const COMPOSITE_IDENTIFIER_REGEXP = '/(\w+)=(?<=\w=)(.*?)(?=;\w+=)|(\w+)=([^;]*);?$/';
private function __construct()
{
}
/*
* Normalize takes a string and gives back an array of identifiers.
*
* For example: foo=0;bar=2 returns ['foo' => 0, 'bar' => 2].
*/
public static function parse(string $identifier): array
{
$matches = [];
$identifiers = [];
$num = preg_match_all(self::COMPOSITE_IDENTIFIER_REGEXP, $identifier, $matches, \PREG_SET_ORDER);
foreach ($matches as $i => $match) {
if ($i === $num - 1) {
$identifiers[$match[3]] = $match[4];
continue;
}
$identifiers[$match[1]] = $match[2];
}
return $identifiers;
}
/**
* Renders composite identifiers to string using: key=value;key2=value2.
*/
public static function stringify(array $identifiers): string
{
$composite = [];
foreach ($identifiers as $name => $value) {
$composite[] = \sprintf('%s=%s', $name, $value);
}
return implode(';', $composite);
}
}

View File

@@ -0,0 +1,35 @@
<?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\Api;
use ApiPlatform\Metadata\Resource\ResourceNameCollection;
/**
* The first path you will see in the API.
*
* @deprecated use ApiPlatform\Documentation\Entrypoint
*
* @author Amrouche Hamza <hamza.simperfit@gmail.com>
*/
final class Entrypoint
{
public function __construct(private readonly ResourceNameCollection $resourceNameCollection)
{
}
public function getResourceNameCollection(): ResourceNameCollection
{
return $this->resourceNameCollection;
}
}

View File

@@ -0,0 +1,72 @@
<?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\Api;
if (interface_exists(\ApiPlatform\Metadata\FilterInterface::class)) {
trigger_deprecation('api-platform', '3.3', \sprintf('%s is deprecated in favor of %s. This class will be removed in 4.0.', FilterInterface::class, \ApiPlatform\Metadata\FilterInterface::class));
class_alias(
\ApiPlatform\Metadata\FilterInterface::class,
__NAMESPACE__.'\FilterInterface'
);
if (false) { // @phpstan-ignore-line
interface FilterInterface extends \ApiPlatform\Metadata\FilterInterface
{
}
}
} else {
/**
* Filters applicable on a resource.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*
* @deprecated
*/
interface FilterInterface
{
/**
* Gets the description of this filter for the given resource.
*
* Returns an array with the filter parameter names as keys and array with the following data as values:
* - property: the property where the filter is applied
* - type: the type of the filter
* - required: if this filter is required
* - strategy (optional): the used strategy
* - is_collection (optional): if this filter is for collection
* - swagger (optional): additional parameters for the path operation,
* e.g. 'swagger' => [
* 'description' => 'My Description',
* 'name' => 'My Name',
* 'type' => 'integer',
* ]
* - openapi (optional): additional parameters for the path operation in the version 3 spec,
* e.g. 'openapi' => [
* 'description' => 'My Description',
* 'name' => 'My Name',
* 'schema' => [
* 'type' => 'integer',
* ]
* ]
* - schema (optional): schema definition,
* e.g. 'schema' => [
* 'type' => 'string',
* 'enum' => ['value_1', 'value_2'],
* ]
* The description can contain additional data specific to a filter.
*
* @see \ApiPlatform\OpenApi\Factory\OpenApiFactory::getFiltersParameters
*/
public function getDescription(string $resourceClass): array;
}
}

View File

@@ -0,0 +1,55 @@
<?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\Api;
use ApiPlatform\Exception\InvalidArgumentException;
use Psr\Container\ContainerInterface;
/**
* Manipulates filters with a backward compatibility between the new filter locator and the deprecated filter collection.
*
* @author Baptiste Meyer <baptiste.meyer@gmail.com>
*
* @deprecated
*
* @internal
*/
trait FilterLocatorTrait
{
private ?ContainerInterface $filterLocator = null;
/**
* Sets a filter locator with a backward compatibility.
*/
private function setFilterLocator(?ContainerInterface $filterLocator, bool $allowNull = false): void
{
if ($filterLocator instanceof ContainerInterface || (null === $filterLocator && $allowNull)) {
$this->filterLocator = $filterLocator;
} else {
throw new InvalidArgumentException(\sprintf('The "$filterLocator" argument is expected to be an implementation of the "%s" interface%s.', ContainerInterface::class, $allowNull ? ' or null' : ''));
}
}
/**
* Gets a filter with a backward compatibility.
*/
private function getFilter(string $filterId): ?FilterInterface
{
if ($this->filterLocator && $this->filterLocator->has($filterId)) {
return $this->filterLocator->get($filterId);
}
return null;
}
}

View File

@@ -0,0 +1,64 @@
<?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\Api;
/**
* Matches a mime type to a format.
*
* @internal
*/
final class FormatMatcher
{
/**
* @var array<string, string[]>
*/
private readonly array $formats;
/**
* @param array<string, string[]|string> $formats
*/
public function __construct(array $formats)
{
$normalizedFormats = [];
foreach ($formats as $format => $mimeTypes) {
$normalizedFormats[$format] = (array) $mimeTypes;
}
$this->formats = $normalizedFormats;
}
/**
* Gets the format associated with the mime type.
*
* Adapted from {@see \Symfony\Component\HttpFoundation\Request::getFormat}.
*/
public function getFormat(string $mimeType): ?string
{
$canonicalMimeType = null;
$pos = strpos($mimeType, ';');
if (false !== $pos) {
$canonicalMimeType = trim(substr($mimeType, 0, $pos));
}
foreach ($this->formats as $format => $mimeTypes) {
if (\in_array($mimeType, $mimeTypes, true)) {
return $format;
}
if (null !== $canonicalMimeType && \in_array($canonicalMimeType, $mimeTypes, true)) {
return $format;
}
}
return null;
}
}

View File

@@ -0,0 +1,167 @@
<?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\Api;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
/**
* {@inheritdoc}
*
* @deprecated use ApiPlatform\Metadata\IdentifiersExtractor instead
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
final class IdentifiersExtractor implements IdentifiersExtractorInterface
{
use ResourceClassInfoTrait;
private readonly PropertyAccessorInterface $propertyAccessor;
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ?PropertyAccessorInterface $propertyAccessor = null)
{
$this->resourceMetadataFactory = $resourceMetadataFactory;
$this->resourceClassResolver = $resourceClassResolver;
$this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
}
/**
* {@inheritdoc}
*
* TODO: 3.0 identifiers should be stringable?
*/
public function getIdentifiersFromItem(object $item, ?Operation $operation = null, array $context = []): array
{
if (!$this->isResourceClass($this->getObjectClass($item))) {
return ['id' => $this->propertyAccessor->getValue($item, 'id')];
}
if ($operation && $operation->getClass()) {
return $this->getIdentifiersFromOperation($item, $operation, $context);
}
$resourceClass = $this->getResourceClass($item, true);
$operation ??= $this->resourceMetadataFactory->create($resourceClass)->getOperation(null, false, true);
return $this->getIdentifiersFromOperation($item, $operation, $context);
}
private function getIdentifiersFromOperation(object $item, Operation $operation, array $context = []): array
{
if ($operation instanceof HttpOperation) {
$links = $operation->getUriVariables();
} elseif ($operation instanceof GraphQlOperation) {
$links = $operation->getLinks();
}
$identifiers = [];
foreach ($links ?? [] as $k => $link) {
$linkIdentifiers = $link->getIdentifiers() ?? [$k];
if (1 < \count($linkIdentifiers)) {
$compositeIdentifiers = [];
foreach ($linkIdentifiers as $identifier) {
$compositeIdentifiers[$identifier] = $this->getIdentifierValue($item, $link->getFromClass() ?? $operation->getClass(), $identifier, $link->getParameterName());
}
$identifiers[$link->getParameterName()] = CompositeIdentifierParser::stringify($compositeIdentifiers);
continue;
}
$parameterName = $link->getParameterName();
$identifiers[$parameterName] = $this->getIdentifierValue($item, $link->getFromClass() ?? $operation->getClass(), $linkIdentifiers[0], $parameterName, $link->getToProperty());
}
return $identifiers;
}
/**
* Gets the value of the given class property.
*/
private function getIdentifierValue(object $item, string $class, string $property, string $parameterName, ?string $toProperty = null): float|bool|int|string
{
if ($item instanceof $class) {
try {
return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, $property), $parameterName);
} catch (NoSuchPropertyException $e) {
throw new RuntimeException('Not able to retrieve identifiers.', $e->getCode(), $e);
}
}
if ($toProperty) {
return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, "$toProperty.$property"), $parameterName);
}
$resourceClass = $this->getResourceClass($item, true);
foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) {
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName);
$types = $propertyMetadata->getBuiltinTypes();
if (null === ($type = $types[0] ?? null)) {
continue;
}
try {
if ($type->isCollection()) {
$collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
if (null !== $collectionValueType && $collectionValueType->getClassName() === $class) {
return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, \sprintf('%s[0].%s', $propertyName, $property)), $parameterName);
}
}
if ($type->getClassName() === $class) {
return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, "$propertyName.$property"), $parameterName);
}
} catch (NoSuchPropertyException $e) {
throw new RuntimeException('Not able to retrieve identifiers.', $e->getCode(), $e);
}
}
throw new RuntimeException('Not able to retrieve identifiers.');
}
/**
* TODO: in 3.0 this method just uses $identifierValue instanceof \Stringable and we remove the weird behavior.
*
* @param mixed|\Stringable $identifierValue
*/
private function resolveIdentifierValue(mixed $identifierValue, string $parameterName): float|bool|int|string
{
if (null === $identifierValue) {
throw new RuntimeException('No identifier value found, did you forget to persist the entity?');
}
if (\is_scalar($identifierValue)) {
return $identifierValue;
}
if ($identifierValue instanceof \Stringable) {
return (string) $identifierValue;
}
if ($identifierValue instanceof \BackedEnum) {
return (string) $identifierValue->value;
}
throw new RuntimeException(\sprintf('We were not able to resolve the identifier matching parameter "%s".', $parameterName));
}
}

View File

@@ -0,0 +1,32 @@
<?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\Api;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Operation;
/**
* Extracts identifiers for a given Resource according to the retrieved Metadata.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
interface IdentifiersExtractorInterface
{
/**
* Finds identifiers from an Item (object).
*
* @throws RuntimeException
*/
public function getIdentifiersFromItem(object $item, ?Operation $operation = null, array $context = []): array;
}

View File

@@ -0,0 +1,45 @@
<?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\Api;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Operation;
/**
* Converts item and resources to IRI and vice versa.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
interface IriConverterInterface
{
/**
* Retrieves an item from its IRI.
*
* @throws InvalidArgumentException
* @throws ItemNotFoundException
*/
public function getResourceFromIri(string $iri, array $context = [], ?Operation $operation = null): object;
/**
* Gets the IRI associated with the given item.
*
* @param object|class-string $resource
*
* @throws InvalidArgumentException
* @throws RuntimeException
*/
public function getIriFromResource(object|string $resource, int $referenceType = UrlGeneratorInterface::ABS_PATH, ?Operation $operation = null, array $context = []): ?string;
}

View File

@@ -0,0 +1,25 @@
<?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\Api\QueryParameterValidator;
use ApiPlatform\ParameterValidator\ParameterValidator as NewQueryParameterValidator;
/**
* Validates query parameters depending on filter description.
*
* @deprecated use ApiPlatform\QueryParameterValidator\QueryParameterValidator instead
*/
class QueryParameterValidator extends NewQueryParameterValidator
{
}

View File

@@ -0,0 +1,91 @@
<?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\Api\QueryParameterValidator\Validator;
use ApiPlatform\ParameterValidator\Validator\CheckFilterDeprecationsTrait;
use ApiPlatform\ParameterValidator\Validator\ValidatorInterface;
/**
* @deprecated use \ApiPlatform\ParameterValidator\Validator\ArrayItems instead
*/
final class ArrayItems implements ValidatorInterface
{
use CheckFilterDeprecationsTrait;
/**
* {@inheritdoc}
*/
public function validate(string $name, array $filterDescription, array $queryParameters): array
{
if (!\array_key_exists($name, $queryParameters)) {
return [];
}
$this->checkFilterDeprecations($filterDescription);
$maxItems = $filterDescription['openapi']['maxItems'] ?? $filterDescription['swagger']['maxItems'] ?? null;
$minItems = $filterDescription['openapi']['minItems'] ?? $filterDescription['swagger']['minItems'] ?? null;
$uniqueItems = $filterDescription['openapi']['uniqueItems'] ?? $filterDescription['swagger']['uniqueItems'] ?? false;
$errorList = [];
$value = $this->getValue($name, $filterDescription, $queryParameters);
$nbItems = \count($value);
if (null !== $maxItems && $nbItems > $maxItems) {
$errorList[] = \sprintf('Query parameter "%s" must contain less than %d values', $name, $maxItems);
}
if (null !== $minItems && $nbItems < $minItems) {
$errorList[] = \sprintf('Query parameter "%s" must contain more than %d values', $name, $minItems);
}
if (true === $uniqueItems && $nbItems > \count(array_unique($value))) {
$errorList[] = \sprintf('Query parameter "%s" must contain unique values', $name);
}
return $errorList;
}
private function getValue(string $name, array $filterDescription, array $queryParameters): array
{
$value = $queryParameters[$name] ?? null;
if (empty($value) && '0' !== $value) {
return [];
}
if (\is_array($value)) {
return $value;
}
$collectionFormat = $filterDescription['openapi']['collectionFormat'] ?? $filterDescription['swagger']['collectionFormat'] ?? 'csv';
return explode(self::getSeparator($collectionFormat), (string) $value) ?: []; // @phpstan-ignore-line
}
/**
* @return non-empty-string
*/
private static function getSeparator(string $collectionFormat): string
{
return match ($collectionFormat) {
'csv' => ',',
'ssv' => ' ',
'tsv' => '\t',
'pipes' => '|',
default => throw new \InvalidArgumentException(\sprintf('Unknown collection format %s', $collectionFormat)),
};
}
}

View File

@@ -0,0 +1,61 @@
<?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\Api\QueryParameterValidator\Validator;
use ApiPlatform\ParameterValidator\Validator\CheckFilterDeprecationsTrait;
use ApiPlatform\ParameterValidator\Validator\ValidatorInterface;
/**
* @deprecated use \ApiPlatform\ParameterValidator\Validator\Bounds instead
*/
final class Bounds implements ValidatorInterface
{
use CheckFilterDeprecationsTrait;
/**
* {@inheritdoc}
*/
public function validate(string $name, array $filterDescription, array $queryParameters): array
{
$value = $queryParameters[$name] ?? null;
if (empty($value) && '0' !== $value) {
return [];
}
$this->checkFilterDeprecations($filterDescription);
$maximum = $filterDescription['openapi']['maximum'] ?? $filterDescription['swagger']['maximum'] ?? null;
$minimum = $filterDescription['openapi']['minimum'] ?? $filterDescription['swagger']['minimum'] ?? null;
$errorList = [];
if (null !== $maximum) {
if (($filterDescription['openapi']['exclusiveMaximum'] ?? $filterDescription['swagger']['exclusiveMaximum'] ?? false) && $value >= $maximum) {
$errorList[] = \sprintf('Query parameter "%s" must be less than %s', $name, $maximum);
} elseif ($value > $maximum) {
$errorList[] = \sprintf('Query parameter "%s" must be less than or equal to %s', $name, $maximum);
}
}
if (null !== $minimum) {
if (($filterDescription['openapi']['exclusiveMinimum'] ?? $filterDescription['swagger']['exclusiveMinimum'] ?? false) && $value <= $minimum) {
$errorList[] = \sprintf('Query parameter "%s" must be greater than %s', $name, $minimum);
} elseif ($value < $minimum) {
$errorList[] = \sprintf('Query parameter "%s" must be greater than or equal to %s', $name, $minimum);
}
}
return $errorList;
}
}

View File

@@ -0,0 +1,48 @@
<?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\Api\QueryParameterValidator\Validator;
use ApiPlatform\ParameterValidator\Validator\CheckFilterDeprecationsTrait;
use ApiPlatform\ParameterValidator\Validator\ValidatorInterface;
/**
* @deprecated use \ApiPlatform\ParameterValidator\Validator\Enum instead
*/
final class Enum implements ValidatorInterface
{
use CheckFilterDeprecationsTrait;
/**
* {@inheritdoc}
*/
public function validate(string $name, array $filterDescription, array $queryParameters): array
{
$value = $queryParameters[$name] ?? null;
if (empty($value) && '0' !== $value || !\is_string($value)) {
return [];
}
$this->checkFilterDeprecations($filterDescription);
$enum = $filterDescription['openapi']['enum'] ?? $filterDescription['swagger']['enum'] ?? null;
if (null !== $enum && !\in_array($value, $enum, true)) {
return [
\sprintf('Query parameter "%s" must be one of "%s"', $name, implode(', ', $enum)),
];
}
return [];
}
}

View File

@@ -0,0 +1,53 @@
<?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\Api\QueryParameterValidator\Validator;
use ApiPlatform\ParameterValidator\Validator\CheckFilterDeprecationsTrait;
use ApiPlatform\ParameterValidator\Validator\ValidatorInterface;
/**
* @deprecated use \ApiPlatform\ParameterValidator\Validator\Length instead
*/
final class Length implements ValidatorInterface
{
use CheckFilterDeprecationsTrait;
/**
* {@inheritdoc}
*/
public function validate(string $name, array $filterDescription, array $queryParameters): array
{
$value = $queryParameters[$name] ?? null;
if (empty($value) && '0' !== $value || !\is_string($value)) {
return [];
}
$this->checkFilterDeprecations($filterDescription);
$maxLength = $filterDescription['openapi']['maxLength'] ?? $filterDescription['swagger']['maxLength'] ?? null;
$minLength = $filterDescription['openapi']['minLength'] ?? $filterDescription['swagger']['minLength'] ?? null;
$errorList = [];
if (null !== $maxLength && mb_strlen($value) > $maxLength) {
$errorList[] = \sprintf('Query parameter "%s" length must be lower than or equal to %s', $name, $maxLength);
}
if (null !== $minLength && mb_strlen($value) < $minLength) {
$errorList[] = \sprintf('Query parameter "%s" length must be greater than or equal to %s', $name, $minLength);
}
return $errorList;
}
}

View File

@@ -0,0 +1,48 @@
<?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\Api\QueryParameterValidator\Validator;
use ApiPlatform\ParameterValidator\Validator\CheckFilterDeprecationsTrait;
use ApiPlatform\ParameterValidator\Validator\ValidatorInterface;
/**
* @deprecated use \ApiPlatform\ParameterValidator\Validator\MultipleOf instead
*/
final class MultipleOf implements ValidatorInterface
{
use CheckFilterDeprecationsTrait;
/**
* {@inheritdoc}
*/
public function validate(string $name, array $filterDescription, array $queryParameters): array
{
$value = $queryParameters[$name] ?? null;
if (empty($value) && '0' !== $value || !\is_string($value)) {
return [];
}
$this->checkFilterDeprecations($filterDescription);
$multipleOf = $filterDescription['openapi']['multipleOf'] ?? $filterDescription['swagger']['multipleOf'] ?? null;
if (null !== $multipleOf && 0 !== ($value % $multipleOf)) {
return [
\sprintf('Query parameter "%s" must multiple of %s', $name, $multipleOf),
];
}
return [];
}
}

View File

@@ -0,0 +1,48 @@
<?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\Api\QueryParameterValidator\Validator;
use ApiPlatform\ParameterValidator\Validator\CheckFilterDeprecationsTrait;
use ApiPlatform\ParameterValidator\Validator\ValidatorInterface;
/**
* @deprecated use \ApiPlatform\ParameterValidator\Validator\Pattern instead
*/
final class Pattern implements ValidatorInterface
{
use CheckFilterDeprecationsTrait;
/**
* {@inheritdoc}
*/
public function validate(string $name, array $filterDescription, array $queryParameters): array
{
$value = $queryParameters[$name] ?? null;
if (empty($value) && '0' !== $value || !\is_string($value)) {
return [];
}
$this->checkFilterDeprecations($filterDescription);
$pattern = $filterDescription['openapi']['pattern'] ?? $filterDescription['swagger']['pattern'] ?? null;
if (null !== $pattern && !preg_match($pattern, $value)) {
return [
\sprintf('Query parameter "%s" must match pattern %s', $name, $pattern),
];
}
return [];
}
}

View File

@@ -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\Api\QueryParameterValidator\Validator;
use ApiPlatform\ParameterValidator\Validator\CheckFilterDeprecationsTrait;
use ApiPlatform\ParameterValidator\Validator\ValidatorInterface;
use ApiPlatform\State\Util\RequestParser;
/**
* @deprecated use \ApiPlatform\ParameterValidator\Validator\Required instead
*/
final class Required implements ValidatorInterface
{
use CheckFilterDeprecationsTrait;
/**
* {@inheritdoc}
*/
public function validate(string $name, array $filterDescription, array $queryParameters): array
{
// filter is not required, the `checkRequired` method can not break
if (!($filterDescription['required'] ?? false)) {
return [];
}
// if query param is not given, then break
if (!$this->requestHasQueryParameter($queryParameters, $name)) {
return [
\sprintf('Query parameter "%s" is required', $name),
];
}
$this->checkFilterDeprecations($filterDescription);
// if query param is empty and the configuration does not allow it
if (!($filterDescription['openapi']['allowEmptyValue'] ?? $filterDescription['swagger']['allowEmptyValue'] ?? false) && empty($this->requestGetQueryParameter($queryParameters, $name))) {
return [
\sprintf('Query parameter "%s" does not allow empty value', $name),
];
}
return [];
}
/**
* Test if request has required parameter.
*/
private function requestHasQueryParameter(array $queryParameters, string $name): bool
{
$matches = RequestParser::parseRequestParams($name);
if (!$matches) {
return false;
}
$rootName = array_keys($matches)[0] ?? '';
if (!$rootName) {
return false;
}
if (\is_array($matches[$rootName])) {
$keyName = array_keys($matches[$rootName])[0];
$queryParameter = $queryParameters[(string) $rootName] ?? null;
return \is_array($queryParameter) && isset($queryParameter[$keyName]);
}
return \array_key_exists((string) $rootName, $queryParameters);
}
/**
* Test if required filter is valid. It validates array notation too like "required[bar]".
*/
private function requestGetQueryParameter(array $queryParameters, string $name)
{
$matches = RequestParser::parseRequestParams($name);
if (empty($matches)) {
return null;
}
$rootName = array_keys($matches)[0] ?? '';
if (!$rootName) {
return null;
}
if (\is_array($matches[$rootName])) {
$keyName = array_keys($matches[$rootName])[0];
$queryParameter = $queryParameters[(string) $rootName] ?? null;
if (\is_array($queryParameter) && isset($queryParameter[$keyName])) {
return $queryParameter[$keyName];
}
return null;
}
return $queryParameters[(string) $rootName];
}
}

View File

@@ -0,0 +1,21 @@
<?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\Api\QueryParameterValidator\Validator;
use ApiPlatform\ParameterValidator\Validator as ParameterValidatorComponent;
/** @deprecated use \ApiPlatform\ParameterValidator\Validator\ValidatorInterface instead */
interface ValidatorInterface extends ParameterValidatorComponent\ValidatorInterface
{
}

View File

@@ -0,0 +1,110 @@
<?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\Api;
use ApiPlatform\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Util\ClassInfoTrait;
/**
* {@inheritdoc}
*
* @deprecated replaced by ApiPlatform\Metadata\ResourceClassResolver
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Samuel ROZE <samuel.roze@gmail.com>
*/
final class ResourceClassResolver implements ResourceClassResolverInterface
{
use ClassInfoTrait;
private array $localIsResourceClassCache = [];
private array $localMostSpecificResourceClassCache = [];
public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory)
{
}
/**
* {@inheritdoc}
*/
public function getResourceClass(mixed $value, ?string $resourceClass = null, bool $strict = false): string
{
if ($strict && null === $resourceClass) {
throw new InvalidArgumentException('Strict checking is only possible when resource class is specified.');
}
$objectClass = \is_object($value) ? $this->getObjectClass($value) : null;
$actualClass = ($objectClass && (!$value instanceof \Traversable || $this->isResourceClass($objectClass))) ? $this->getObjectClass($value) : null;
if (null === $actualClass && null === $resourceClass) {
throw new InvalidArgumentException('Resource type could not be determined. Resource class must be specified.');
}
if (null !== $actualClass && !$this->isResourceClass($actualClass)) {
throw new InvalidArgumentException(\sprintf('No resource class found for object of type "%s".', $actualClass));
}
if (null !== $resourceClass && !$this->isResourceClass($resourceClass)) {
throw new InvalidArgumentException(\sprintf('Specified class "%s" is not a resource class.', $resourceClass));
}
if ($strict && null !== $actualClass && !is_a($actualClass, $resourceClass, true)) {
throw new InvalidArgumentException(\sprintf('Object of type "%s" does not match "%s" resource class.', $actualClass, $resourceClass));
}
$targetClass = $actualClass ?? $resourceClass;
if (isset($this->localMostSpecificResourceClassCache[$targetClass])) {
return $this->localMostSpecificResourceClassCache[$targetClass];
}
$mostSpecificResourceClass = null;
foreach ($this->resourceNameCollectionFactory->create() as $resourceClassName) {
if (!is_a($targetClass, $resourceClassName, true)) {
continue;
}
if (null === $mostSpecificResourceClass || is_subclass_of($resourceClassName, $mostSpecificResourceClass)) {
$mostSpecificResourceClass = $resourceClassName;
}
}
if (null === $mostSpecificResourceClass) {
throw new \LogicException('Unexpected execution flow.');
}
$this->localMostSpecificResourceClassCache[$targetClass] = $mostSpecificResourceClass;
return $mostSpecificResourceClass;
}
/**
* {@inheritdoc}
*/
public function isResourceClass(string $type): bool
{
if (isset($this->localIsResourceClassCache[$type])) {
return $this->localIsResourceClassCache[$type];
}
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
if (is_a($type, $resourceClass, true)) {
return $this->localIsResourceClassCache[$type] = true;
}
}
return $this->localIsResourceClassCache[$type] = false;
}
}

View File

@@ -0,0 +1,39 @@
<?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\Api;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
/**
* Guesses which resource is associated with a given object.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
interface ResourceClassResolverInterface
{
/**
* Guesses the associated resource.
*
* @param string $resourceClass The expected resource class
* @param bool $strict If true, value must match the expected resource class
*
* @throws InvalidArgumentException
*/
public function getResourceClass(mixed $value, ?string $resourceClass = null, bool $strict = false): string;
/**
* Is the given class a resource class?
*/
public function isResourceClass(string $type): bool;
}

View File

@@ -0,0 +1,43 @@
<?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\Api\UriVariableTransformer;
use ApiPlatform\Api\UriVariableTransformerInterface;
use ApiPlatform\Exception\InvalidUriVariableException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
final class DateTimeUriVariableTransformer implements UriVariableTransformerInterface
{
private readonly DateTimeNormalizer $dateTimeNormalizer;
public function __construct()
{
$this->dateTimeNormalizer = new DateTimeNormalizer();
}
public function transform(mixed $value, array $types, array $context = []): \DateTimeInterface
{
try {
return $this->dateTimeNormalizer->denormalize($value, $types[0], null, $context);
} catch (NotNormalizableValueException $e) {
throw new InvalidUriVariableException($e->getMessage(), $e->getCode(), $e);
}
}
public function supportsTransformation(mixed $value, array $types, array $context = []): bool
{
return $this->dateTimeNormalizer->supportsDenormalization($value, $types[0]);
}
}

View File

@@ -0,0 +1,30 @@
<?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\Api\UriVariableTransformer;
use ApiPlatform\Api\UriVariableTransformerInterface;
use Symfony\Component\PropertyInfo\Type;
final class IntegerUriVariableTransformer implements UriVariableTransformerInterface
{
public function transform(mixed $value, array $types, array $context = []): int
{
return (int) $value;
}
public function supportsTransformation(mixed $value, array $types, array $context = []): bool
{
return Type::BUILTIN_TYPE_INT === $types[0] && \is_string($value);
}
}

View File

@@ -0,0 +1,39 @@
<?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\Api;
use ApiPlatform\Exception\InvalidUriVariableException;
interface UriVariableTransformerInterface
{
/**
* Transforms the value of a URI variable (identifier) to its type.
*
* @param mixed $value The URI variable value to transform
* @param array $types The guessed type behind the URI variable
* @param array $context Options available to the transformer
*
* @throws InvalidUriVariableException Occurs when the URI variable could not be transformed
*/
public function transform(mixed $value, array $types, array $context = []);
/**
* Checks whether the value of a URI variable can be transformed to its type by this transformer.
*
* @param mixed $value The URI variable value to transform
* @param array $types The types to which the URI variable value should be transformed
* @param array $context Options available to the transformer
*/
public function supportsTransformation(mixed $value, array $types, array $context = []): bool;
}

View File

@@ -0,0 +1,91 @@
<?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\Api;
use ApiPlatform\Exception\InvalidUriVariableException;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use Symfony\Component\PropertyInfo\Type;
/**
* UriVariables converter that chains uri variables transformers.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
final class UriVariablesConverter implements UriVariablesConverterInterface
{
/**
* @param iterable<UriVariableTransformerInterface> $uriVariableTransformers
*/
public function __construct(private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly iterable $uriVariableTransformers)
{
}
/**
* {@inheritdoc}
*
* To handle the composite identifiers type correctly, use an `uri_variables_map` that maps uriVariables to their uriVariablesDefinition.
* Indeed, a composite identifier will already be parsed, and their corresponding properties will be the parameterName and not the defined
* identifiers.
*/
public function convert(array $uriVariables, string $class, array $context = []): array
{
$operation = $context['operation'] ?? $this->resourceMetadataCollectionFactory->create($class)->getOperation();
$context += ['operation' => $operation];
$uriVariablesDefinitions = $operation->getUriVariables() ?? [];
foreach ($uriVariables as $parameterName => $value) {
$uriVariableDefinition = $context['uri_variables_map'][$parameterName] ?? $uriVariablesDefinitions[$parameterName] ?? $uriVariablesDefinitions['id'] ?? new Link();
// When a composite identifier is used, we assume that the parameterName is the property to find our type
$properties = $uriVariableDefinition->getIdentifiers() ?? [$parameterName];
if ($uriVariableDefinition->getCompositeIdentifier()) {
$properties = [$parameterName];
}
if (!$types = $this->getIdentifierTypes($uriVariableDefinition->getFromClass() ?? $class, $properties)) {
continue;
}
foreach ($this->uriVariableTransformers as $uriVariableTransformer) {
if (!$uriVariableTransformer->supportsTransformation($value, $types, $context)) {
continue;
}
try {
$uriVariables[$parameterName] = $uriVariableTransformer->transform($value, $types, $context);
break;
} catch (InvalidUriVariableException $e) {
throw new InvalidUriVariableException(\sprintf('Identifier "%s" could not be transformed.', $parameterName), $e->getCode(), $e);
}
}
}
return $uriVariables;
}
private function getIdentifierTypes(string $resourceClass, array $properties): array
{
$types = [];
foreach ($properties as $property) {
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property);
foreach ($propertyMetadata->getBuiltinTypes() as $type) {
$types[] = Type::BUILTIN_TYPE_OBJECT === ($builtinType = $type->getBuiltinType()) ? $type->getClassName() : $builtinType;
}
}
return $types;
}
}

View File

@@ -0,0 +1,36 @@
<?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\Api;
use ApiPlatform\Metadata\Exception\InvalidIdentifierException;
/**
* Identifier converter.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
interface UriVariablesConverterInterface
{
/**
* Takes an array of strings representing URI variables (identifiers) and transform their values to the expected type.
*
* @param array $data URI variables to convert to PHP values
* @param string $class The class to which the URI variables belong to
*
* @throws InvalidIdentifierException
*
* @return array Array indexed by identifiers properties with their values denormalized
*/
public function convert(array $data, string $class, array $context = []): array;
}

View File

@@ -0,0 +1,86 @@
<?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\Api;
use Symfony\Component\Routing\Exception\InvalidParameterException;
use Symfony\Component\Routing\Exception\MissingMandatoryParametersException;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
/**
* UrlGeneratorInterface is the interface that all URL generator classes must implement.
*
* This interface has been imported and adapted from the Symfony project.
*
* The constants in this interface define the different types of resource references that
* are declared in RFC 3986: http://tools.ietf.org/html/rfc3986
* We are using the term "URL" instead of "URI" as this is more common in web applications
* and we do not need to distinguish them as the difference is mostly semantical and
* less technical. Generating URIs, i.e. representation-independent resource identifiers,
* is also possible.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Tobias Schultze <http://tobion.de>
* @copyright Fabien Potencier
*
* @deprecated moved to ApiPlatform\Metadata\UrlGeneratorInterface
*/
interface UrlGeneratorInterface
{
/**
* Generates an absolute URL, e.g. "http://example.com/dir/file".
*/
public const ABS_URL = 0;
/**
* Generates an absolute path, e.g. "/dir/file".
*/
public const ABS_PATH = 1;
/**
* Generates a relative path based on the current request path, e.g. "../parent-file".
*
* @see UrlGenerator::getRelativePath()
*/
public const REL_PATH = 2;
/**
* Generates a network path, e.g. "//example.com/dir/file".
* Such reference reuses the current scheme but specifies the host.
*/
public const NET_PATH = 3;
/**
* Generates a URL or path for a specific route based on the given parameters.
*
* Parameters that reference placeholders in the route pattern will substitute them in the
* path or host. Extra params are added as query string to the URL.
*
* When the passed reference type cannot be generated for the route because it requires a different
* host or scheme than the current one, the method will return a more comprehensive reference
* that includes the required params. For example, when you call this method with $referenceType = ABSOLUTE_PATH
* but the route requires the https scheme whereas the current scheme is http, it will instead return an
* ABSOLUTE_URL with the https scheme and the current host. This makes sure the generated URL matches
* the route in any case.
*
* If there is no route with the given name, the generator must throw the RouteNotFoundException.
*
* The special parameter _fragment will be used as the document fragment suffixed to the final URL.
*
* @throws RouteNotFoundException If the named route doesn't exist
* @throws MissingMandatoryParametersException When some parameters are missing that are mandatory for the route
* @throws InvalidParameterException When a parameter value for a placeholder is not correct because
* it does not match the requirement
*/
public function generate(string $name, array $parameters = [], int $referenceType = self::ABS_PATH): string;
}

View File

@@ -0,0 +1,20 @@
<?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\ApiResource;
use ApiPlatform\State\ApiResource\Error as ApiResourceError;
class Error extends ApiResourceError
{
}

View File

@@ -0,0 +1,98 @@
<?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\Doctrine\Common;
use ApiPlatform\State\Pagination\PaginatorInterface;
use Doctrine\Common\Collections\ReadableCollection;
/**
* @template T of object
*
* @implements PaginatorInterface<T>
* @implements \IteratorAggregate<T>
*/
final class CollectionPaginator implements \IteratorAggregate, PaginatorInterface
{
/**
* @var array<array-key,T>
*/
private readonly array $items;
private readonly float $totalItems;
/**
* @param ReadableCollection<array-key,T> $collection
*/
public function __construct(
readonly ReadableCollection $collection,
private readonly float $currentPage,
private readonly float $itemsPerPage,
) {
$this->items = $collection->slice((int) (($currentPage - 1) * $itemsPerPage), $itemsPerPage > 0 ? (int) $itemsPerPage : null);
$this->totalItems = $collection->count();
}
/**
* {@inheritdoc}
*/
public function getCurrentPage(): float
{
return $this->currentPage;
}
/**
* {@inheritdoc}
*/
public function getLastPage(): float
{
if (0. >= $this->itemsPerPage) {
return 1.;
}
return max(ceil($this->totalItems / $this->itemsPerPage) ?: 1., 1.);
}
/**
* {@inheritdoc}
*/
public function getItemsPerPage(): float
{
return $this->itemsPerPage;
}
/**
* {@inheritdoc}
*/
public function getTotalItems(): float
{
return $this->totalItems;
}
/**
* {@inheritdoc}
*/
public function count(): int
{
return \count($this->items);
}
/**
* {@inheritdoc}
*
* @return \Traversable<T>
*/
public function getIterator(): \Traversable
{
yield from $this->items;
}
}

View File

@@ -0,0 +1,98 @@
<?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\Doctrine\Common\Filter;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use Psr\Log\LoggerInterface;
/**
* Trait for filtering the collection by backed enum values.
*
* Filters collection on equality of backed enum properties.
*
* For each property passed, if the resource does not have such property or if
* the value is not one of cases the property is ignored.
*
* @author Rémi Marseille <marseille.remi@gmail.com>
*/
trait BackedEnumFilterTrait
{
use PropertyHelperTrait;
/**
* @var array<string, string>
*/
private array $enumTypes;
/**
* {@inheritdoc}
*/
public function getDescription(string $resourceClass): array
{
$description = [];
$properties = $this->getProperties();
if (null === $properties) {
$properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null);
}
foreach ($properties as $property => $unused) {
if (!$this->isPropertyMapped($property, $resourceClass) || !$this->isBackedEnumField($property, $resourceClass)) {
continue;
}
$propertyName = $this->normalizePropertyName($property);
$description[$propertyName] = [
'property' => $propertyName,
'type' => 'string',
'required' => false,
'schema' => [
'type' => 'string',
'enum' => array_map(fn (\BackedEnum $case) => $case->value, $this->enumTypes[$property]::cases()),
],
];
}
return $description;
}
abstract protected function getProperties(): ?array;
abstract protected function getLogger(): LoggerInterface;
abstract protected function normalizePropertyName(string $property): string;
/**
* Determines whether the given property refers to a backed enum field.
*/
abstract protected function isBackedEnumField(string $property, string $resourceClass): bool;
private function normalizeValue($value, string $property): mixed
{
$values = array_map(fn (\BackedEnum $case) => $case->value, $this->enumTypes[$property]::cases());
if (\in_array($value, $values, true)) {
return $value;
}
$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('Invalid backed enum value for "%s" property, expected one of ( "%s" )',
$property,
implode('" | "', $values)
)),
]);
return null;
}
}

View File

@@ -0,0 +1,99 @@
<?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\Doctrine\Common\Filter;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use Psr\Log\LoggerInterface;
/**
* Trait for filtering the collection by boolean values.
*
* Filters collection on equality of boolean properties. The value is specified
* as one of ( "true" | "false" | "1" | "0" ) in the query.
*
* For each property passed, if the resource does not have such property or if
* the value is not one of ( "true" | "false" | "1" | "0" ) the property is ignored.
*
* @author Amrouche Hamza <hamza.simperfit@gmail.com>
* @author Teoh Han Hui <teohhanhui@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
trait BooleanFilterTrait
{
use PropertyHelperTrait;
/**
* {@inheritdoc}
*/
public function getDescription(string $resourceClass): array
{
$description = [];
$properties = $this->getProperties();
if (null === $properties) {
$properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null);
}
foreach ($properties as $property => $unused) {
if (!$this->isPropertyMapped($property, $resourceClass) || !$this->isBooleanField($property, $resourceClass)) {
continue;
}
$propertyName = $this->normalizePropertyName($property);
$description[$propertyName] = [
'property' => $propertyName,
'type' => 'bool',
'required' => false,
];
}
return $description;
}
abstract protected function getProperties(): ?array;
abstract protected function getLogger(): LoggerInterface;
abstract protected function normalizePropertyName(string $property): string;
/**
* Determines whether the given property refers to a boolean field.
*/
protected function isBooleanField(string $property, string $resourceClass): bool
{
return isset(self::DOCTRINE_BOOLEAN_TYPES[(string) $this->getDoctrineFieldType($property, $resourceClass)]);
}
private function normalizeValue($value, string $property): ?bool
{
if (\in_array($value, [true, 'true', '1'], true)) {
return true;
}
if (\in_array($value, [false, 'false', '0'], true)) {
return false;
}
$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $property, implode('" | "', [
'true',
'false',
'1',
'0',
]))),
]);
return null;
}
}

View File

@@ -0,0 +1,33 @@
<?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\Doctrine\Common\Filter;
/**
* Interface for filtering the collection by date intervals.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Théo FIDRY <theo.fidry@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
interface DateFilterInterface
{
public const PARAMETER_BEFORE = 'before';
public const PARAMETER_STRICTLY_BEFORE = 'strictly_before';
public const PARAMETER_AFTER = 'after';
public const PARAMETER_STRICTLY_AFTER = 'strictly_after';
public const EXCLUDE_NULL = 'exclude_null';
public const INCLUDE_NULL_BEFORE = 'include_null_before';
public const INCLUDE_NULL_AFTER = 'include_null_after';
public const INCLUDE_NULL_BEFORE_AND_AFTER = 'include_null_before_and_after';
}

View File

@@ -0,0 +1,96 @@
<?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\Doctrine\Common\Filter;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
/**
* Trait for filtering the collection by date intervals.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Théo FIDRY <theo.fidry@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
trait DateFilterTrait
{
use PropertyHelperTrait;
/**
* {@inheritdoc}
*/
public function getDescription(string $resourceClass): array
{
$description = [];
$properties = $this->getProperties();
if (null === $properties) {
$properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null);
}
foreach ($properties as $property => $nullManagement) {
if (!$this->isPropertyMapped($property, $resourceClass) || !$this->isDateField($property, $resourceClass)) {
continue;
}
$description += $this->getFilterDescription($property, self::PARAMETER_BEFORE);
$description += $this->getFilterDescription($property, self::PARAMETER_STRICTLY_BEFORE);
$description += $this->getFilterDescription($property, self::PARAMETER_AFTER);
$description += $this->getFilterDescription($property, self::PARAMETER_STRICTLY_AFTER);
}
return $description;
}
abstract protected function getProperties(): ?array;
abstract protected function normalizePropertyName(string $property): string;
/**
* Determines whether the given property refers to a date field.
*/
protected function isDateField(string $property, string $resourceClass): bool
{
return isset(self::DOCTRINE_DATE_TYPES[(string) $this->getDoctrineFieldType($property, $resourceClass)]);
}
/**
* Gets filter description.
*/
protected function getFilterDescription(string $property, string $period): array
{
$propertyName = $this->normalizePropertyName($property);
return [
\sprintf('%s[%s]', $propertyName, $period) => [
'property' => $propertyName,
'type' => \DateTimeInterface::class,
'required' => false,
],
];
}
private function normalizeValue($value, string $operator): ?string
{
if (false === \is_string($value)) {
$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('Invalid value for "[%s]", expected string', $operator)),
]);
return null;
}
return $value;
}
}

View File

@@ -0,0 +1,25 @@
<?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\Doctrine\Common\Filter;
/**
* Interface for filtering the collection by whether a property value exists or not.
*
* @author Teoh Han Hui <teohhanhui@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
interface ExistsFilterInterface
{
public const QUERY_PARAMETER_KEY = 'exists';
}

View File

@@ -0,0 +1,94 @@
<?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\Doctrine\Common\Filter;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use Psr\Log\LoggerInterface;
/**
* Trait for filtering the collection by whether a property value exists or not.
*
* @author Teoh Han Hui <teohhanhui@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
trait ExistsFilterTrait
{
use PropertyHelperTrait;
/**
* @var string Keyword used to retrieve the value
*/
private readonly string $existsParameterName;
/**
* {@inheritdoc}
*/
public function getDescription(string $resourceClass): array
{
$description = [];
$properties = $this->getProperties();
if (null === $properties) {
$properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null);
}
foreach ($properties as $property => $unused) {
if (!$this->isPropertyMapped($property, $resourceClass, true) || !$this->isNullableField($property, $resourceClass)) {
continue;
}
$propertyName = $this->normalizePropertyName($property);
$description[\sprintf('%s[%s]', $this->existsParameterName, $propertyName)] = [
'property' => $propertyName,
'type' => 'bool',
'required' => false,
];
}
return $description;
}
/**
* Determines whether the given property refers to a nullable field.
*/
abstract protected function isNullableField(string $property, string $resourceClass): bool;
abstract protected function getProperties(): ?array;
abstract protected function getLogger(): LoggerInterface;
abstract protected function normalizePropertyName(string $property): string;
private function normalizeValue($value, string $property): ?bool
{
if (\in_array($value, [true, 'true', '1', '', null], true)) {
return true;
}
if (\in_array($value, [false, 'false', '0'], true)) {
return false;
}
$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('Invalid value for "%s[%s]", expected one of ( "%s" )', $this->existsParameterName, $property, implode('" | "', [
'true',
'false',
'1',
'0',
]))),
]);
return null;
}
}

View File

@@ -0,0 +1,124 @@
<?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\Doctrine\Common\Filter;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use Psr\Log\LoggerInterface;
/**
* Trait for filtering the collection by numeric values.
*
* @author Amrouche Hamza <hamza.simperfit@gmail.com>
* @author Teoh Han Hui <teohhanhui@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
trait NumericFilterTrait
{
use PropertyHelperTrait;
/**
* {@inheritdoc}
*/
public function getDescription(string $resourceClass): array
{
$description = [];
$properties = $this->getProperties();
if (null === $properties) {
$properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null);
}
foreach ($properties as $property => $unused) {
if (!$this->isPropertyMapped($property, $resourceClass) || !$this->isNumericField($property, $resourceClass)) {
continue;
}
$propertyName = $this->normalizePropertyName($property);
$filterParameterNames = [$propertyName, $propertyName.'[]'];
foreach ($filterParameterNames as $filterParameterName) {
$description[$filterParameterName] = [
'property' => $propertyName,
'type' => $this->getType((string) $this->getDoctrineFieldType($property, $resourceClass)),
'required' => false,
'is_collection' => str_ends_with((string) $filterParameterName, '[]'),
];
}
}
return $description;
}
/**
* Gets the PHP type corresponding to this Doctrine type.
*/
abstract protected function getType(?string $doctrineType = null): string;
abstract protected function getProperties(): ?array;
abstract protected function getLogger(): LoggerInterface;
abstract protected function normalizePropertyName(string $property): string;
/**
* Determines whether the given property refers to a numeric field.
*/
protected function isNumericField(string $property, string $resourceClass): bool
{
return isset(self::DOCTRINE_NUMERIC_TYPES[(string) $this->getDoctrineFieldType($property, $resourceClass)]);
}
protected function normalizeValues($value, string $property): ?array
{
if (!is_numeric($value) && (!\is_array($value) || !$this->isNumericArray($value))) {
$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('Invalid numeric value for "%s" property', $property)),
]);
return null;
}
$values = (array) $value;
foreach ($values as $key => $val) {
if (!\is_int($key)) {
unset($values[$key]);
continue;
}
$values[$key] = $val + 0; // coerce $val to the right type.
}
if (empty($values)) {
$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('At least one value is required, multiple values should be in "%1$s[]=firstvalue&%1$s[]=secondvalue" format', $property)),
]);
return null;
}
return array_values($values);
}
protected function isNumericArray(array $values): bool
{
foreach ($values as $value) {
if (!is_numeric($value)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,49 @@
<?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\Doctrine\Common\Filter;
/**
* Interface for ordering the collection by given properties.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Théo FIDRY <theo.fidry@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
interface OrderFilterInterface
{
public const DIRECTION_ASC = 'ASC';
public const DIRECTION_DESC = 'DESC';
public const NULLS_SMALLEST = 'nulls_smallest';
public const NULLS_LARGEST = 'nulls_largest';
public const NULLS_ALWAYS_FIRST = 'nulls_always_first';
public const NULLS_ALWAYS_LAST = 'nulls_always_last';
public const NULLS_DIRECTION_MAP = [
self::NULLS_SMALLEST => [
'ASC' => 'ASC',
'DESC' => 'DESC',
],
self::NULLS_LARGEST => [
'ASC' => 'DESC',
'DESC' => 'ASC',
],
self::NULLS_ALWAYS_FIRST => [
'ASC' => 'ASC',
'DESC' => 'ASC',
],
self::NULLS_ALWAYS_LAST => [
'ASC' => 'DESC',
'DESC' => 'DESC',
],
];
}

View File

@@ -0,0 +1,87 @@
<?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\Doctrine\Common\Filter;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
/**
* Trait for ordering the collection by given properties.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Théo FIDRY <theo.fidry@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
trait OrderFilterTrait
{
use PropertyHelperTrait;
/**
* @var string Keyword used to retrieve the value
*/
protected string $orderParameterName;
/**
* {@inheritdoc}
*/
public function getDescription(string $resourceClass): array
{
$description = [];
$properties = $this->getProperties();
if (null === $properties && $fieldNames = $this->getClassMetadata($resourceClass)->getFieldNames()) {
$properties = array_fill_keys($fieldNames, null);
}
foreach ($properties ?? [] as $property => $propertyOptions) {
if (!$this->isPropertyMapped($property, $resourceClass)) {
continue;
}
$propertyName = $this->normalizePropertyName($property);
$description[\sprintf('%s[%s]', $this->orderParameterName, $propertyName)] = [
'property' => $propertyName,
'type' => 'string',
'required' => false,
'schema' => [
'type' => 'string',
'default' => strtolower($propertyOptions['default_direction'] ?? OrderFilterInterface::DIRECTION_ASC),
'enum' => [
strtolower(OrderFilterInterface::DIRECTION_ASC),
strtolower(OrderFilterInterface::DIRECTION_DESC),
],
],
];
}
return $description;
}
abstract protected function getProperties(): ?array;
abstract protected function normalizePropertyName(string $property): string;
private function normalizeValue($value, string $property): ?string
{
if (empty($value) && null !== $defaultDirection = $this->getProperties()[$property]['default_direction'] ?? null) {
// fallback to default direction
$value = $defaultDirection;
}
$value = strtoupper($value);
if (!\in_array($value, [self::DIRECTION_ASC, self::DIRECTION_DESC], true)) {
return null;
}
return $value;
}
}

View File

@@ -0,0 +1,27 @@
<?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\Doctrine\Common\Filter;
/**
* @author Antoine Bluchet <soyuka@gmail.com>
*
* @experimental
*/
interface PropertyAwareFilterInterface
{
/**
* @param string[] $properties
*/
public function setProperties(array $properties): void;
}

View File

@@ -0,0 +1,29 @@
<?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\Doctrine\Common\Filter;
/**
* Interface for filtering the collection by range.
*
* @author Lee Siong Chan <ahlee2326@me.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
interface RangeFilterInterface
{
public const PARAMETER_BETWEEN = 'between';
public const PARAMETER_GREATER_THAN = 'gt';
public const PARAMETER_GREATER_THAN_OR_EQUAL = 'gte';
public const PARAMETER_LESS_THAN = 'lt';
public const PARAMETER_LESS_THAN_OR_EQUAL = 'lte';
}

View File

@@ -0,0 +1,139 @@
<?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\Doctrine\Common\Filter;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use Psr\Log\LoggerInterface;
/**
* Trait for filtering the collection by range.
*
* @author Lee Siong Chan <ahlee2326@me.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
trait RangeFilterTrait
{
use PropertyHelperTrait;
/**
* {@inheritdoc}
*/
public function getDescription(string $resourceClass): array
{
$description = [];
$properties = $this->getProperties();
if (null === $properties) {
$properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null);
}
foreach ($properties as $property => $unused) {
if (!$this->isPropertyMapped($property, $resourceClass)) {
continue;
}
$description += $this->getFilterDescription($property, self::PARAMETER_BETWEEN);
$description += $this->getFilterDescription($property, self::PARAMETER_GREATER_THAN);
$description += $this->getFilterDescription($property, self::PARAMETER_GREATER_THAN_OR_EQUAL);
$description += $this->getFilterDescription($property, self::PARAMETER_LESS_THAN);
$description += $this->getFilterDescription($property, self::PARAMETER_LESS_THAN_OR_EQUAL);
}
return $description;
}
abstract protected function getProperties(): ?array;
abstract protected function getLogger(): LoggerInterface;
abstract protected function normalizePropertyName(string $property): string;
/**
* Gets filter description.
*/
protected function getFilterDescription(string $fieldName, string $operator): array
{
$propertyName = $this->normalizePropertyName($fieldName);
return [
\sprintf('%s[%s]', $propertyName, $operator) => [
'property' => $propertyName,
'type' => 'string',
'required' => false,
],
];
}
private function normalizeValues(array $values, string $property): ?array
{
$operators = [self::PARAMETER_BETWEEN, self::PARAMETER_GREATER_THAN, self::PARAMETER_GREATER_THAN_OR_EQUAL, self::PARAMETER_LESS_THAN, self::PARAMETER_LESS_THAN_OR_EQUAL];
foreach ($values as $operator => $value) {
if (!\in_array($operator, $operators, true)) {
unset($values[$operator]);
}
}
if (empty($values)) {
$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('At least one valid operator ("%s") is required for "%s" property', implode('", "', $operators), $property)),
]);
return null;
}
return $values;
}
/**
* Normalize the values array for between operator.
*/
private function normalizeBetweenValues(array $values): ?array
{
if (2 !== \count($values)) {
$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('Invalid format for "[%s]", expected "<min>..<max>"', self::PARAMETER_BETWEEN)),
]);
return null;
}
if (!is_numeric($values[0]) || !is_numeric($values[1])) {
$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('Invalid values for "[%s]" range, expected numbers', self::PARAMETER_BETWEEN)),
]);
return null;
}
return [$values[0] + 0, $values[1] + 0]; // coerce to the right types.
}
/**
* Normalize the value.
*/
private function normalizeValue(string $value, string $operator): float|int|null
{
if (!is_numeric($value)) {
$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('Invalid value for "[%s]", expected number', $operator)),
]);
return null;
}
return $value + 0; // coerce $value to the right type.
}
}

View File

@@ -0,0 +1,73 @@
<?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\Doctrine\Common\Filter;
/**
* Interface for filtering the collection by given properties.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
interface SearchFilterInterface
{
/**
* @var string Exact matching
*/
public const STRATEGY_EXACT = 'exact';
/**
* @var string Exact matching case-insensitive
*/
public const STRATEGY_IEXACT = 'iexact';
/**
* @var string The value must be contained in the field
*/
public const STRATEGY_PARTIAL = 'partial';
/**
* @var string The value must be contained in the field case-insensitive
*/
public const STRATEGY_IPARTIAL = 'ipartial';
/**
* @var string Finds fields that are starting with the value
*/
public const STRATEGY_START = 'start';
/**
* @var string Finds fields that are starting with the value case-insensitive
*/
public const STRATEGY_ISTART = 'istart';
/**
* @var string Finds fields that are ending with the value
*/
public const STRATEGY_END = 'end';
/**
* @var string Finds fields that are ending with the value case-insensitive
*/
public const STRATEGY_IEND = 'iend';
/**
* @var string Finds fields that are starting with the word
*/
public const STRATEGY_WORD_START = 'word_start';
/**
* @var string Finds fields that are starting with the word case-insensitive
*/
public const STRATEGY_IWORD_START = 'iword_start';
}

View File

@@ -0,0 +1,178 @@
<?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\Doctrine\Common\Filter;
use ApiPlatform\Api\IdentifiersExtractorInterface as LegacyIdentifiersExtractorInterface;
use ApiPlatform\Api\IriConverterInterface as LegacyIriConverterInterface;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
use ApiPlatform\Metadata\IriConverterInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
/**
* Trait for filtering the collection by given properties.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
trait SearchFilterTrait
{
use PropertyHelperTrait;
protected IriConverterInterface|LegacyIriConverterInterface $iriConverter;
protected PropertyAccessorInterface $propertyAccessor;
protected IdentifiersExtractorInterface|LegacyIdentifiersExtractorInterface|null $identifiersExtractor = null;
/**
* {@inheritdoc}
*/
public function getDescription(string $resourceClass): array
{
$description = [];
$properties = $this->getProperties();
if (null === $properties) {
$properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null);
}
foreach ($properties as $property => $strategy) {
if (!$this->isPropertyMapped($property, $resourceClass, true)) {
continue;
}
if ($this->isPropertyNested($property, $resourceClass)) {
$propertyParts = $this->splitPropertyParts($property, $resourceClass);
$field = $propertyParts['field'];
$metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']);
} else {
$field = $property;
$metadata = $this->getClassMetadata($resourceClass);
}
$propertyName = $this->normalizePropertyName($property);
if ($metadata->hasField($field)) {
$typeOfField = $this->getType($metadata->getTypeOfField($field));
$strategy = $this->getProperties()[$property] ?? self::STRATEGY_EXACT;
$filterParameterNames = [$propertyName];
if (\in_array($strategy, [self::STRATEGY_EXACT, self::STRATEGY_IEXACT], true)) {
$filterParameterNames[] = $propertyName.'[]';
}
foreach ($filterParameterNames as $filterParameterName) {
$description[$filterParameterName] = [
'property' => $propertyName,
'type' => $typeOfField,
'required' => false,
'strategy' => $strategy,
'is_collection' => str_ends_with((string) $filterParameterName, '[]'),
];
}
} elseif ($metadata->hasAssociation($field)) {
$filterParameterNames = [
$propertyName,
$propertyName.'[]',
];
foreach ($filterParameterNames as $filterParameterName) {
$description[$filterParameterName] = [
'property' => $propertyName,
'type' => 'string',
'required' => false,
'strategy' => self::STRATEGY_EXACT,
'is_collection' => str_ends_with((string) $filterParameterName, '[]'),
];
}
}
}
return $description;
}
/**
* Converts a Doctrine type in PHP type.
*/
abstract protected function getType(string $doctrineType): string;
abstract protected function getProperties(): ?array;
abstract protected function getLogger(): LoggerInterface;
abstract protected function getIriConverter(): LegacyIriConverterInterface|IriConverterInterface;
abstract protected function getPropertyAccessor(): PropertyAccessorInterface;
abstract protected function normalizePropertyName(string $property): string;
/**
* Gets the ID from an IRI or a raw ID.
*/
protected function getIdFromValue(string $value): mixed
{
try {
$iriConverter = $this->getIriConverter();
$item = $iriConverter->getResourceFromIri($value, ['fetch_data' => false]);
if (null === $this->identifiersExtractor) {
return $this->getPropertyAccessor()->getValue($item, 'id');
}
$identifiers = $this->identifiersExtractor->getIdentifiersFromItem($item);
return 1 === \count($identifiers) ? array_pop($identifiers) : $identifiers;
} catch (InvalidArgumentException) {
// Do nothing, return the raw value
}
return $value;
}
/**
* Normalize the values array.
*/
protected function normalizeValues(array $values, string $property): ?array
{
foreach ($values as $key => $value) {
if (!\is_int($key) || !(\is_string($value) || \is_int($value))) {
unset($values[$key]);
}
}
if (empty($values)) {
$this->getLogger()->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('At least one value is required, multiple values should be in "%1$s[]=firstvalue&%1$s[]=secondvalue" format', $property)),
]);
return null;
}
return array_values($values);
}
/**
* When the field should be an integer, check that the given value is a valid one.
*/
protected function hasValidValues(array $values, ?string $type = null): bool
{
foreach ($values as $value) {
if (null !== $value && \in_array($type, (array) self::DOCTRINE_INTEGER_TYPE, true) && false === filter_var($value, \FILTER_VALIDATE_INT)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,21 @@
The MIT license
Copyright (c) 2015-present Kévin Dunglas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,52 @@
<?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\Doctrine\Common\Messenger;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\HandlerFailedException;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @internal
*/
trait DispatchTrait
{
private ?MessageBusInterface $messageBus;
/**
* @param object|Envelope $message
*/
private function dispatch(object $message): Envelope
{
if (!$this->messageBus instanceof MessageBusInterface) {
throw new \InvalidArgumentException('The message bus is not set.');
}
if (!class_exists(HandlerFailedException::class)) {
return $this->messageBus->dispatch($message);
}
try {
return $this->messageBus->dispatch($message);
} catch (HandlerFailedException $e) {
// unwrap the exception thrown in handler for Symfony Messenger >= 4.3
while ($e instanceof HandlerFailedException) {
/** @var \Throwable $e */
$e = $e->getPrevious();
}
throw $e;
}
}
}

View File

@@ -0,0 +1,32 @@
<?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\Doctrine\Common;
use ApiPlatform\Metadata\Parameter;
trait ParameterValueExtractorTrait
{
/**
* @return array<string, mixed>
*/
private function extractParameterValue(Parameter $parameter, mixed $value): array
{
$key = $parameter->getProperty() ?? $parameter->getKey();
if (!str_contains($key, ':property')) {
return [$key => $value];
}
return [str_replace('[:property]', '', $key) => $value];
}
}

View File

@@ -0,0 +1,130 @@
<?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\Doctrine\Common;
use Doctrine\Persistence\Mapping\ClassMetadata;
/**
* Helper trait for getting information regarding a property using the resource metadata.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Théo FIDRY <theo.fidry@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
trait PropertyHelperTrait
{
/**
* Gets class metadata for the given resource.
*/
abstract protected function getClassMetadata(string $resourceClass): ClassMetadata;
/**
* Determines whether the given property is mapped.
*/
protected function isPropertyMapped(string $property, string $resourceClass, bool $allowAssociation = false): bool
{
if ($this->isPropertyNested($property, $resourceClass)) {
$propertyParts = $this->splitPropertyParts($property, $resourceClass);
$metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']);
$property = $propertyParts['field'];
} else {
$metadata = $this->getClassMetadata($resourceClass);
}
return $metadata->hasField($property) || ($allowAssociation && $metadata->hasAssociation($property));
}
/**
* Determines whether the given property is nested.
*/
protected function isPropertyNested(string $property, string $resourceClass): bool
{
$pos = strpos($property, '.');
if (false === $pos) {
return false;
}
return $this->getClassMetadata($resourceClass)->hasAssociation(substr($property, 0, $pos));
}
/**
* Determines whether the given property is embedded.
*/
protected function isPropertyEmbedded(string $property, string $resourceClass): bool
{
return str_contains($property, '.') && $this->getClassMetadata($resourceClass)->hasField($property);
}
/**
* Splits the given property into parts.
*
* Returns an array with the following keys:
* - associations: array of associations according to nesting order
* - field: string holding the actual field (leaf node)
*/
protected function splitPropertyParts(string $property, string $resourceClass): array
{
$parts = explode('.', $property);
$metadata = $this->getClassMetadata($resourceClass);
$slice = 0;
foreach ($parts as $part) {
if ($metadata->hasAssociation($part)) {
$metadata = $this->getClassMetadata($metadata->getAssociationTargetClass($part));
++$slice;
}
}
if (\count($parts) === $slice) {
--$slice;
}
return [
'associations' => \array_slice($parts, 0, $slice),
'field' => implode('.', \array_slice($parts, $slice)),
];
}
/**
* Gets the Doctrine Type of a given property/resourceClass.
*/
protected function getDoctrineFieldType(string $property, string $resourceClass): ?string
{
$propertyParts = $this->splitPropertyParts($property, $resourceClass);
$metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']);
return $metadata->getTypeOfField($propertyParts['field']);
}
/**
* Gets nested class metadata for the given resource.
*
* @param string[] $associations
*/
protected function getNestedMetadata(string $resourceClass, array $associations): ClassMetadata
{
$metadata = $this->getClassMetadata($resourceClass);
foreach ($associations as $association) {
if ($metadata->hasAssociation($association)) {
$associationClass = $metadata->getAssociationTargetClass($association);
$metadata = $this->getClassMetadata($associationClass);
}
}
return $metadata;
}
}

View File

@@ -0,0 +1,105 @@
<?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\Doctrine\Common;
use ApiPlatform\State\Pagination\PaginatorInterface;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
/**
* @template T of object
*
* @implements PaginatorInterface<T>
* @implements \IteratorAggregate<T>
*/
final class SelectablePaginator implements \IteratorAggregate, PaginatorInterface
{
/**
* @var ReadableCollection<array-key,T>
*/
private readonly ReadableCollection $slicedCollection;
private readonly float $totalItems;
/**
* @param Selectable<array-key,T> $selectable
*/
public function __construct(
readonly Selectable $selectable,
private readonly float $currentPage,
private readonly float $itemsPerPage,
) {
$this->totalItems = $this->selectable instanceof \Countable ? $this->selectable->count() : $this->selectable->matching(Criteria::create())->count();
$criteria = Criteria::create()
->setFirstResult((int) (($currentPage - 1) * $itemsPerPage))
->setMaxResults($itemsPerPage > 0 ? (int) $itemsPerPage : null);
$this->slicedCollection = $selectable->matching($criteria);
}
/**
* {@inheritdoc}
*/
public function getCurrentPage(): float
{
return $this->currentPage;
}
/**
* {@inheritdoc}
*/
public function getLastPage(): float
{
if (0. >= $this->itemsPerPage) {
return 1.;
}
return max(ceil($this->totalItems / $this->itemsPerPage) ?: 1., 1.);
}
/**
* {@inheritdoc}
*/
public function getItemsPerPage(): float
{
return $this->itemsPerPage;
}
/**
* {@inheritdoc}
*/
public function getTotalItems(): float
{
return $this->totalItems;
}
/**
* {@inheritdoc}
*/
public function count(): int
{
return $this->slicedCollection->count();
}
/**
* {@inheritdoc}
*
* @return \Traversable<T>
*/
public function getIterator(): \Traversable
{
return $this->slicedCollection->getIterator();
}
}

View File

@@ -0,0 +1,82 @@
<?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\Doctrine\Common;
use ApiPlatform\State\Pagination\PartialPaginatorInterface;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
/**
* @template T of object
*
* @implements PartialPaginatorInterface<T>
* @implements \IteratorAggregate<T>
*/
final class SelectablePartialPaginator implements \IteratorAggregate, PartialPaginatorInterface
{
/**
* @var ReadableCollection<array-key,T>
*/
private readonly ReadableCollection $slicedCollection;
/**
* @param Selectable<array-key,T> $selectable
*/
public function __construct(
readonly Selectable $selectable,
private readonly float $currentPage,
private readonly float $itemsPerPage,
) {
$criteria = Criteria::create()
->setFirstResult((int) (($currentPage - 1) * $itemsPerPage))
->setMaxResults((int) $itemsPerPage);
$this->slicedCollection = $selectable->matching($criteria);
}
/**
* {@inheritdoc}
*/
public function getCurrentPage(): float
{
return $this->currentPage;
}
/**
* {@inheritdoc}
*/
public function getItemsPerPage(): float
{
return $this->itemsPerPage;
}
/**
* {@inheritdoc}
*/
public function count(): int
{
return $this->slicedCollection->count();
}
/**
* {@inheritdoc}
*
* @return \Traversable<T>
*/
public function getIterator(): \Traversable
{
return $this->slicedCollection->getIterator();
}
}

View File

@@ -0,0 +1,44 @@
<?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\Doctrine\Common\State;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Operation;
use Psr\Container\ContainerInterface;
/**
* @internal
*/
trait LinksHandlerLocatorTrait
{
private ?ContainerInterface $handleLinksLocator;
private function getLinksHandler(Operation $operation): ?callable
{
if (!($options = $operation->getStateOptions()) || !$options instanceof Options) {
return null;
}
$handleLinks = $options->getHandleLinks();
if (\is_callable($handleLinks)) {
return $handleLinks;
}
if ($this->handleLinksLocator && \is_string($handleLinks) && $this->handleLinksLocator->has($handleLinks)) {
return [$this->handleLinksLocator->get($handleLinks), 'handleLinks'];
}
throw new RuntimeException(\sprintf('Could not find handleLinks service "%s"', $handleLinks));
}
}

View File

@@ -0,0 +1,122 @@
<?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\Doctrine\Common\State;
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
trait LinksHandlerTrait
{
private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory;
/**
* @param array{linkClass?: string, linkProperty?: string}&array<string, mixed> $context
*
* @return \ApiPlatform\Metadata\Link[]
*/
private function getLinks(string $resourceClass, Operation $operation, array $context): array
{
$links = $this->getOperationLinks($operation);
if (!($linkClass = $context['linkClass'] ?? false)) {
return $links;
}
$newLink = null;
$linkProperty = $context['linkProperty'] ?? null;
foreach ($links as $link) {
if ($linkClass === $link->getFromClass() && $linkProperty === $link->getFromProperty()) {
$newLink = $link;
break;
}
}
if ($newLink) {
return [$newLink];
}
if (!$this->resourceMetadataCollectionFactory) {
return [];
}
// Using GraphQL, it's possible that we won't find a GraphQL Operation of the same type (e.g. it is disabled).
try {
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($linkClass);
$linkedOperation = $resourceMetadataCollection->getOperation($operation->getName());
} catch (OperationNotFoundException $e) {
if (!$operation instanceof GraphQlOperation) {
throw $e;
}
// Instead, we'll look for the first Query available.
foreach ($resourceMetadataCollection as $resourceMetadata) {
foreach ($resourceMetadata->getGraphQlOperations() as $op) {
if ($op instanceof Query) {
$linkedOperation = $op;
}
}
}
}
foreach ($this->getOperationLinks($linkedOperation ?? null) as $link) {
if ($resourceClass === $link->getToClass() && $linkProperty === $link->getFromProperty()) {
$newLink = $link;
break;
}
}
if (!$newLink) {
throw new RuntimeException(\sprintf('The class "%s" cannot be retrieved from "%s".', $resourceClass, $linkClass));
}
return [$newLink];
}
/**
* @param array<int|string,mixed> $identifiers
*/
private function getIdentifierValue(array &$identifiers, ?string $name = null): mixed
{
if (null !== $name && isset($identifiers[$name])) {
$value = $identifiers[$name];
unset($identifiers[$name]);
return $value;
}
return array_shift($identifiers);
}
/**
* @return \ApiPlatform\Metadata\Link[]|array
*/
private function getOperationLinks(?Operation $operation = null): array
{
if ($operation instanceof GraphQlOperation) {
return $operation->getLinks() ?? [];
}
if ($operation instanceof HttpOperation) {
return $operation->getUriVariables() ?? [];
}
return [];
}
}

View File

@@ -0,0 +1,40 @@
<?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\Doctrine\Common\State;
use ApiPlatform\State\OptionsInterface;
class Options implements OptionsInterface
{
/**
* @param mixed $handleLinks experimental callable, typed mixed as we may want a service name in the future
*/
public function __construct(
protected mixed $handleLinks = null,
) {
}
public function getHandleLinks(): mixed
{
return $this->handleLinks;
}
public function withHandleLinks(mixed $handleLinks): self
{
$self = clone $this;
$self->handleLinks = $handleLinks;
return $self;
}
}

View File

@@ -0,0 +1,143 @@
<?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\Doctrine\Common\State;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Util\ClassInfoTrait;
use ApiPlatform\State\ProcessorInterface;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager as DoctrineObjectManager;
final class PersistProcessor implements ProcessorInterface
{
use ClassInfoTrait;
use LinksHandlerTrait;
public function __construct(private readonly ManagerRegistry $managerRegistry)
{
}
/**
* @template T
*
* @param T $data
*
* @return T
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (
!\is_object($data)
|| !$manager = $this->managerRegistry->getManagerForClass($class = $this->getObjectClass($data))
) {
return $data;
}
// PUT: reset the existing object managed by Doctrine and merge data sent by the user in it
// This custom logic is needed because EntityManager::merge() has been deprecated and UPSERT isn't supported:
// https://github.com/doctrine/orm/issues/8461#issuecomment-1250233555
if ($operation instanceof HttpOperation && 'PUT' === $operation->getMethod() && ($operation->getExtraProperties()['standard_put'] ?? false)) {
\assert(method_exists($manager, 'getReference'));
$newData = $data;
$identifiers = array_reverse($uriVariables);
$links = $this->getLinks($class, $operation, $context);
$reflectionProperties = $this->getReflectionProperties($data);
// TODO: the call to getReference is most likely to fail with complex identifiers
if ($previousData = $context['previous_data']) {
$classMetadata = $manager->getClassMetadata($class);
$identifiers = $classMetadata->getIdentifierValues($previousData);
$newData = 1 === \count($identifiers) ? $manager->getReference($class, current($identifiers)) : clone $previousData;
foreach ($reflectionProperties as $propertyName => $reflectionProperty) {
// // Don't override the property if it's part of the subresource system
if (isset($identifiers[$propertyName]) || isset($uriVariables[$propertyName])) {
continue;
}
// Skip URI variables as sometime an uri variable is not the doctrine identifier
foreach ($links as $link) {
if (\in_array($propertyName, $link->getIdentifiers(), true)) {
continue 2;
}
}
if (($newValue = $reflectionProperty->getValue($data)) !== $reflectionProperty->getValue($newData)) {
$reflectionProperty->setValue($newData, $newValue);
}
}
} else {
foreach (array_reverse($links) as $link) {
if ($link->getExpandedValue() || !$link->getFromClass()) {
continue;
}
$identifierProperties = $link->getIdentifiers();
$hasCompositeIdentifiers = 1 < \count($identifierProperties);
foreach ($identifierProperties as $identifierProperty) {
$reflectionProperty = $reflectionProperties[$identifierProperty];
$reflectionProperty->setValue($newData, $this->getIdentifierValue($identifiers, $hasCompositeIdentifiers ? $identifierProperty : null));
}
}
}
$data = $newData;
}
if (!$manager->contains($data) || $this->isDeferredExplicit($manager, $data)) {
$manager->persist($data);
}
$manager->flush();
$manager->refresh($data);
return $data;
}
/**
* Checks if doctrine does not manage data automatically.
*/
private function isDeferredExplicit(DoctrineObjectManager $manager, $data): bool
{
$classMetadata = $manager->getClassMetadata($this->getObjectClass($data));
if ($classMetadata && method_exists($classMetadata, 'isChangeTrackingDeferredExplicit')) { // @phpstan-ignore-line metadata can be null
return $classMetadata->isChangeTrackingDeferredExplicit();
}
return false;
}
/**
* Get reflection properties indexed by property name.
*
* @return array<string, \ReflectionProperty>
*/
private function getReflectionProperties(mixed $data): array
{
$ret = [];
$r = new \ReflectionObject($data);
do {
$props = $r->getProperties(~\ReflectionProperty::IS_STATIC);
foreach ($props as $prop) {
$ret[$prop->getName()] = $prop;
}
} while ($r = $r->getParentClass());
return $ret;
}
}

View File

@@ -0,0 +1,47 @@
<?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\Doctrine\Common\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Util\ClassInfoTrait;
use ApiPlatform\State\ProcessorInterface;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager as DoctrineObjectManager;
final class RemoveProcessor implements ProcessorInterface
{
use ClassInfoTrait;
public function __construct(private readonly ManagerRegistry $managerRegistry)
{
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
if (!$manager = $this->getManager($data)) {
return;
}
$manager->remove($data);
$manager->flush();
}
/**
* Gets the Doctrine object manager associated with given data.
*/
private function getManager($data): ?DoctrineObjectManager
{
return \is_object($data) ? $this->managerRegistry->getManagerForClass($this->getObjectClass($data)) : null;
}
}

View File

@@ -0,0 +1,313 @@
<?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\Doctrine\EventListener;
use ApiPlatform\Api\IriConverterInterface as LegacyIriConverterInterface;
use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface;
use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface as GraphQlMercureSubscriptionIriGeneratorInterface;
use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface as GraphQlSubscriptionManagerInterface;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
use ApiPlatform\Symfony\Messenger\DispatchTrait;
use Doctrine\Common\EventArgs;
use Doctrine\ODM\MongoDB\Event\OnFlushEventArgs as MongoDbOdmOnFlushEventArgs;
use Doctrine\ORM\Event\OnFlushEventArgs as OrmOnFlushEventArgs;
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Mercure\HubRegistry;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Publishes resources updates to the Mercure hub.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*
* @deprecated moved to \ApiPlatform\Doctrine\Common\EventListener\PublishMercureUpdatesListener
*/
final class PublishMercureUpdatesListener
{
use DispatchTrait;
use ResourceClassInfoTrait;
private const ALLOWED_KEYS = [
'topics' => true,
'data' => true,
'private' => true,
'id' => true,
'type' => true,
'retry' => true,
'normalization_context' => true,
'hub' => true,
'enable_async_update' => true,
];
private readonly ?ExpressionLanguage $expressionLanguage;
private \SplObjectStorage $createdObjects;
private \SplObjectStorage $updatedObjects;
private \SplObjectStorage $deletedObjects;
/**
* @param array<string, string[]|string> $formats
*/
public function __construct(LegacyResourceClassResolverInterface|ResourceClassResolverInterface $resourceClassResolver, private readonly LegacyIriConverterInterface|IriConverterInterface $iriConverter, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly SerializerInterface $serializer, private readonly array $formats, ?MessageBusInterface $messageBus = null, private readonly ?HubRegistry $hubRegistry = null, private readonly ?GraphQlSubscriptionManagerInterface $graphQlSubscriptionManager = null, private readonly ?GraphQlMercureSubscriptionIriGeneratorInterface $graphQlMercureSubscriptionIriGenerator = null, ?ExpressionLanguage $expressionLanguage = null, private bool $includeType = false)
{
if (null === $messageBus && null === $hubRegistry) {
throw new InvalidArgumentException('A message bus or a hub registry must be provided.');
}
$this->resourceClassResolver = $resourceClassResolver;
$this->resourceMetadataFactory = $resourceMetadataFactory;
$this->messageBus = $messageBus;
$this->expressionLanguage = $expressionLanguage ?? (class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null);
$this->reset();
if ($this->expressionLanguage) {
$rawurlencode = ExpressionFunction::fromPhp('rawurlencode', 'escape');
$this->expressionLanguage->addFunction($rawurlencode);
$this->expressionLanguage->addFunction(
new ExpressionFunction('get_operation', static fn (string $apiResource, string $name): string => \sprintf('getOperation(%s, %s)', $apiResource, $name), static fn (array $arguments, $apiResource, string $name): Operation => $resourceMetadataFactory->create($resourceClassResolver->getResourceClass($apiResource))->getOperation($name))
);
$this->expressionLanguage->addFunction(
new ExpressionFunction('iri', static fn (string $apiResource, int $referenceType = UrlGeneratorInterface::ABS_URL, ?string $operation = null): string => \sprintf('iri(%s, %d, %s)', $apiResource, $referenceType, $operation), static fn (array $arguments, $apiResource, int $referenceType = UrlGeneratorInterface::ABS_URL, $operation = null): string => $iriConverter->getIriFromResource($apiResource, $referenceType, $operation))
);
}
if (false === $this->includeType) {
trigger_deprecation('api-platform/core', '3.1', 'Having mercure.include_type (always include @type in Mercure updates, even delete ones) set to false in the configuration is deprecated. It will be true by default in API Platform 4.0.');
}
}
/**
* Collects created, updated and deleted objects.
*/
public function onFlush(EventArgs $eventArgs): void
{
if ($eventArgs instanceof OrmOnFlushEventArgs) {
// @phpstan-ignore-next-line
$uow = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager()->getUnitOfWork() : $eventArgs->getEntityManager()->getUnitOfWork();
} elseif ($eventArgs instanceof MongoDbOdmOnFlushEventArgs) {
$uow = $eventArgs->getDocumentManager()->getUnitOfWork();
} else {
return;
}
$methodName = $eventArgs instanceof OrmOnFlushEventArgs ? 'getScheduledEntityInsertions' : 'getScheduledDocumentInsertions';
foreach ($uow->{$methodName}() as $object) {
$this->storeObjectToPublish($object, 'createdObjects');
}
$methodName = $eventArgs instanceof OrmOnFlushEventArgs ? 'getScheduledEntityUpdates' : 'getScheduledDocumentUpdates';
foreach ($uow->{$methodName}() as $object) {
$this->storeObjectToPublish($object, 'updatedObjects');
}
$methodName = $eventArgs instanceof OrmOnFlushEventArgs ? 'getScheduledEntityDeletions' : 'getScheduledDocumentDeletions';
foreach ($uow->{$methodName}() as $object) {
$this->storeObjectToPublish($object, 'deletedObjects');
}
}
/**
* Publishes updates for changes collected on flush, and resets the store.
*/
public function postFlush(): void
{
try {
foreach ($this->createdObjects as $object) {
$this->publishUpdate($object, $this->createdObjects[$object], 'create');
}
foreach ($this->updatedObjects as $object) {
$this->publishUpdate($object, $this->updatedObjects[$object], 'update');
}
foreach ($this->deletedObjects as $object) {
$this->publishUpdate($object, $this->deletedObjects[$object], 'delete');
}
} finally {
$this->reset();
}
}
private function reset(): void
{
$this->createdObjects = new \SplObjectStorage();
$this->updatedObjects = new \SplObjectStorage();
$this->deletedObjects = new \SplObjectStorage();
}
private function storeObjectToPublish(object $object, string $property): void
{
if (null === $resourceClass = $this->getResourceClass($object)) {
return;
}
$operation = $this->resourceMetadataFactory->create($resourceClass)->getOperation();
try {
$options = $operation->getMercure() ?? false;
} catch (OperationNotFoundException) {
return;
}
if (\is_string($options)) {
if (null === $this->expressionLanguage) {
throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".');
}
$options = $this->expressionLanguage->evaluate($options, ['object' => $object]);
}
if (false === $options) {
return;
}
if (true === $options) {
$options = [];
}
if (!\is_array($options)) {
throw new InvalidArgumentException(\sprintf('The value of the "mercure" attribute of the "%s" resource class must be a boolean, an array of options or an expression returning this array, "%s" given.', $resourceClass, \gettype($options)));
}
foreach ($options as $key => $value) {
if (!isset(self::ALLOWED_KEYS[$key])) {
throw new InvalidArgumentException(\sprintf('The option "%s" set in the "mercure" attribute of the "%s" resource does not exist. Existing options: "%s"', $key, $resourceClass, implode('", "', self::ALLOWED_KEYS)));
}
}
$options['enable_async_update'] ??= true;
if ('deletedObjects' === $property) {
$types = $operation instanceof HttpOperation ? $operation->getTypes() : null;
if (null === $types) {
$types = [$operation->getShortName()];
}
// We need to evaluate it here, because in publishUpdate() the resource would be already deleted
$this->evaluateTopics($options, $object);
$this->deletedObjects[(object) [
'id' => $this->iriConverter->getIriFromResource($object),
'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL),
'type' => 1 === \count($types) ? $types[0] : $types,
]] = $options;
return;
}
$this->{$property}[$object] = $options;
}
private function publishUpdate(object $object, array $options, string $type): void
{
if ($object instanceof \stdClass) {
// By convention, if the object has been deleted, we send only its IRI and its type.
// This may change in the feature, because it's not JSON Merge Patch compliant,
// and I'm not a fond of this approach.
$iri = $options['topics'] ?? $object->iri;
/** @var string $data */
$data = json_encode(['@id' => $object->id] + ($this->includeType ? ['@type' => $object->type] : []), \JSON_THROW_ON_ERROR);
} else {
$resourceClass = $this->getObjectClass($object);
$context = $options['normalization_context'] ?? $this->resourceMetadataFactory->create($resourceClass)->getOperation()->getNormalizationContext() ?? [];
// We need to evaluate it here, because in storeObjectToPublish() the resource would not have been persisted yet
$this->evaluateTopics($options, $object);
$iri = $options['topics'] ?? $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL);
$data = $options['data'] ?? $this->serializer->serialize($object, key($this->formats), $context);
}
$updates = array_merge([$this->buildUpdate($iri, $data, $options)], $this->getGraphQlSubscriptionUpdates($object, $options, $type));
foreach ($updates as $update) {
if ($options['enable_async_update'] && $this->messageBus) {
$this->dispatch($update);
continue;
}
$this->hubRegistry->getHub($options['hub'] ?? null)->publish($update);
}
}
private function evaluateTopics(array &$options, object $object): void
{
if (!($options['topics'] ?? false)) {
return;
}
$topics = [];
foreach ((array) $options['topics'] as $topic) {
if (!\is_string($topic)) {
$topics[] = $topic;
continue;
}
if (!str_starts_with($topic, '@=')) {
$topics[] = $topic;
continue;
}
if (null === $this->expressionLanguage) {
throw new \LogicException('The "@=" expression syntax cannot be used without the Expression Language component. Try running "composer require symfony/expression-language".');
}
$topics[] = $this->expressionLanguage->evaluate(substr($topic, 2), ['object' => $object]);
}
$options['topics'] = $topics;
}
/**
* @return Update[]
*/
private function getGraphQlSubscriptionUpdates(object $object, array $options, string $type): array
{
if ('update' !== $type || !$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) {
return [];
}
$payloads = $this->graphQlSubscriptionManager->getPushPayloads($object);
$updates = [];
foreach ($payloads as [$subscriptionId, $data]) {
$updates[] = $this->buildUpdate(
$this->graphQlMercureSubscriptionIriGenerator->generateTopicIri($subscriptionId),
(string) (new JsonResponse($data))->getContent(),
$options
);
}
return $updates;
}
/**
* @param string|string[] $iri
*/
private function buildUpdate(string|array $iri, string $data, array $options): Update
{
return new Update($iri, $data, $options['private'] ?? false, $options['id'] ?? null, $options['type'] ?? null, $options['retry'] ?? null);
}
}

View File

@@ -0,0 +1,184 @@
<?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\Doctrine\EventListener;
use ApiPlatform\Api\IriConverterInterface as LegacyIriConverterInterface;
use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface;
use ApiPlatform\HttpCache\PurgerInterface;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use ApiPlatform\Metadata\Util\ClassInfoTrait;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Mapping\AssociationMapping;
use Doctrine\ORM\PersistentCollection;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
/**
* Purges responses containing modified entities from the proxy cache.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*
* @deprecated moved to \ApiPlatform\Doctrine\Common\EventListener\PurgeHttpCacheListener
*/
final class PurgeHttpCacheListener
{
use ClassInfoTrait;
private readonly PropertyAccessorInterface $propertyAccessor;
private array $tags = [];
public function __construct(private readonly PurgerInterface $purger, private readonly IriConverterInterface|LegacyIriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null)
{
$this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
}
/**
* Collects tags from the previous and the current version of the updated entities to purge related documents.
*/
public function preUpdate(PreUpdateEventArgs $eventArgs): void
{
$object = $eventArgs->getObject();
$this->gatherResourceAndItemTags($object, true);
$changeSet = $eventArgs->getEntityChangeSet();
// @phpstan-ignore-next-line
$objectManager = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager() : $eventArgs->getEntityManager();
$associationMappings = $objectManager->getClassMetadata(\get_class($eventArgs->getObject()))->getAssociationMappings();
foreach ($changeSet as $key => $value) {
if (!isset($associationMappings[$key])) {
continue;
}
$this->addTagsFor($value[0]);
$this->addTagsFor($value[1]);
}
}
/**
* Collects tags from inserted and deleted entities, including relations.
*/
public function onFlush(OnFlushEventArgs $eventArgs): void
{
// @phpstan-ignore-next-line
$em = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager() : $eventArgs->getEntityManager();
$uow = $em->getUnitOfWork();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$this->gatherResourceAndItemTags($entity, false);
$this->gatherRelationTags($em, $entity);
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
$this->gatherResourceAndItemTags($entity, true);
$this->gatherRelationTags($em, $entity);
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
$this->gatherResourceAndItemTags($entity, true);
$this->gatherRelationTags($em, $entity);
}
}
/**
* Purges tags collected during this request, and clears the tag list.
*/
public function postFlush(): void
{
if (empty($this->tags)) {
return;
}
$this->purger->purge(array_values($this->tags));
$this->tags = [];
}
private function gatherResourceAndItemTags(object $entity, bool $purgeItem): void
{
try {
$resourceClass = $this->resourceClassResolver->getResourceClass($entity);
$iri = $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, new GetCollection());
$this->tags[$iri] = $iri;
if ($purgeItem) {
$this->addTagForItem($entity);
}
} catch (OperationNotFoundException|InvalidArgumentException) {
}
}
private function gatherRelationTags(EntityManagerInterface $em, object $entity): void
{
$associationMappings = $em->getClassMetadata($entity::class)->getAssociationMappings();
/** @var array|AssociationMapping $associationMapping according to the version of doctrine orm */
foreach ($associationMappings as $property => $associationMapping) {
if ($associationMapping instanceof AssociationMapping && ($associationMapping->targetEntity ?? null) && !$this->resourceClassResolver->isResourceClass($associationMapping->targetEntity)) {
return;
}
if (
\is_array($associationMapping)
&& \array_key_exists('targetEntity', $associationMapping)
&& !$this->resourceClassResolver->isResourceClass($associationMapping['targetEntity'])) {
return;
}
if ($this->propertyAccessor->isReadable($entity, $property)) {
$this->addTagsFor($this->propertyAccessor->getValue($entity, $property));
}
}
}
private function addTagsFor(mixed $value): void
{
if (!$value || \is_scalar($value)) {
return;
}
if (!is_iterable($value)) {
$this->addTagForItem($value);
return;
}
if ($value instanceof PersistentCollection) {
$value = clone $value;
}
foreach ($value as $v) {
$this->addTagForItem($v);
}
}
private function addTagForItem(mixed $value): void
{
if (!$this->resourceClassResolver->isResourceClass($this->getObjectClass($value))) {
return;
}
try {
$iri = $this->iriConverter->getIriFromResource($value);
$this->tags[$iri] = $iri;
} catch (RuntimeException|InvalidArgumentException) {
}
}
}

View File

@@ -0,0 +1,27 @@
<?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\Doctrine\Odm\Extension;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
/**
* Interface of Doctrine MongoDB ODM aggregation extensions for collection aggregations.
*
* @author Alan Poulain <contact@alanpoulain.eu>
*/
interface AggregationCollectionExtensionInterface
{
public function applyToCollection(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void;
}

View File

@@ -0,0 +1,27 @@
<?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\Doctrine\Odm\Extension;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
/**
* Interface of Doctrine MongoDB ODM aggregation extensions for item aggregations.
*
* @author Alan Poulain <contact@alanpoulain.eu>
*/
interface AggregationItemExtensionInterface
{
public function applyToItem(Builder $aggregationBuilder, string $resourceClass, array $identifiers, ?Operation $operation = null, array &$context = []): void;
}

View File

@@ -0,0 +1,30 @@
<?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\Doctrine\Odm\Extension;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
/**
* Interface of Doctrine MongoDB ODM aggregation extensions that supports result production
* for specific cases such as pagination.
*
* @author Alan Poulain <contact@alanpoulain.eu>
*/
interface AggregationResultCollectionExtensionInterface extends AggregationCollectionExtensionInterface
{
public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool;
public function getResult(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array $context = []): iterable;
}

View File

@@ -0,0 +1,30 @@
<?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\Doctrine\Odm\Extension;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
/**
* Interface of Doctrine MongoDB ODM aggregation extensions that supports result production
* for specific cases such as Aggregation alteration.
*
* @author Alan Poulain <contact@alanpoulain.eu>
*/
interface AggregationResultItemExtensionInterface extends AggregationItemExtensionInterface
{
public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool;
public function getResult(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array $context = []): ?object;
}

View File

@@ -0,0 +1,52 @@
<?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\Doctrine\Odm\Extension;
use ApiPlatform\Doctrine\Odm\Filter\FilterInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Psr\Container\ContainerInterface;
/**
* Applies filters on a resource aggregation.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Samuel ROZE <samuel.roze@gmail.com>
*/
final class FilterExtension implements AggregationCollectionExtensionInterface
{
public function __construct(private readonly ContainerInterface $filterLocator)
{
}
/**
* {@inheritdoc}
*/
public function applyToCollection(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
$resourceFilters = $operation?->getFilters();
if (empty($resourceFilters)) {
return;
}
foreach ($resourceFilters as $filterId) {
$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
if ($filter instanceof FilterInterface) {
$context['filters'] ??= [];
$filter->apply($aggregationBuilder, $resourceClass, $operation, $context);
}
}
}
}

View File

@@ -0,0 +1,113 @@
<?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\Doctrine\Odm\Extension;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\Aggregation\Stage\Sort;
use Doctrine\Persistence\ManagerRegistry;
/**
* Applies selected ordering while querying resource collection.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Samuel ROZE <samuel.roze@gmail.com>
* @author Vincent Chalamon <vincentchalamon@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class OrderExtension implements AggregationCollectionExtensionInterface
{
use MongoDbOdmPropertyHelperTrait;
use PropertyHelperTrait;
public function __construct(private readonly ?string $order = null, private readonly ?ManagerRegistry $managerRegistry = null)
{
}
/**
* {@inheritdoc}
*/
public function applyToCollection(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
// Do not apply order if already defined on $aggregationBuilder
if ($this->hasSortStage($aggregationBuilder)) {
return;
}
$classMetaData = $this->getClassMetadata($resourceClass);
$identifiers = $classMetaData->getIdentifier();
if (isset($context['operation'])) {
$defaultOrder = $context['operation']->getOrder() ?? [];
} else {
$defaultOrder = $operation?->getOrder();
}
if ($defaultOrder) {
foreach ($defaultOrder as $field => $order) {
if (\is_int($field)) {
// Default direction
$field = $order;
$order = 'ASC';
}
if ($this->isPropertyNested($field, $resourceClass)) {
[$field] = $this->addLookupsForNestedProperty($field, $aggregationBuilder, $resourceClass, true);
}
$aggregationBuilder->sort(
$context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$field => $order]
);
}
return;
}
if (null !== $this->order) {
foreach ($identifiers as $identifier) {
$aggregationBuilder->sort(
$context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$identifier => $this->order]
);
}
}
}
protected function getManagerRegistry(): ManagerRegistry
{
return $this->managerRegistry;
}
private function hasSortStage(Builder $aggregationBuilder): bool
{
$shouldStop = false;
$index = 0;
do {
try {
if ($aggregationBuilder->getStage($index) instanceof Sort) {
// If at least one stage is sort, then it has sorting
return true;
}
} catch (\OutOfRangeException $outOfRangeException) {
// There is no more stages on the aggregation builder
$shouldStop = true;
}
++$index;
} while (!$shouldStop);
// No stage was sort, and we iterated through all stages
return false;
}
}

View File

@@ -0,0 +1,127 @@
<?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\Doctrine\Odm\Extension;
use ApiPlatform\Doctrine\Odm\Paginator;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* Applies pagination on the Doctrine aggregation for resource collection when enabled.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Samuel ROZE <samuel.roze@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class PaginationExtension implements AggregationResultCollectionExtensionInterface
{
public function __construct(private readonly ManagerRegistry $managerRegistry, private readonly Pagination $pagination)
{
}
/**
* {@inheritdoc}
*
* @throws RuntimeException
*/
public function applyToCollection(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
if (!$this->pagination->isEnabled($operation, $context)) {
return;
}
if (($context['graphql_operation_name'] ?? false) && !$this->pagination->isGraphQlEnabled($operation, $context)) {
return;
}
$context = $this->addCountToContext(clone $aggregationBuilder, $context);
[, $offset, $limit] = $this->pagination->getPagination($operation, $context);
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
if (!$manager instanceof DocumentManager) {
throw new RuntimeException(\sprintf('The manager for "%s" must be an instance of "%s".', $resourceClass, DocumentManager::class));
}
/**
* @var DocumentRepository
*/
$repository = $manager->getRepository($resourceClass);
$resultsAggregationBuilder = $repository->createAggregationBuilder()->skip($offset);
if ($limit > 0) {
$resultsAggregationBuilder->limit($limit);
} else {
// Results have to be 0 but MongoDB does not support a limit equal to 0.
$resultsAggregationBuilder->match()->field(Paginator::LIMIT_ZERO_MARKER_FIELD)->equals(Paginator::LIMIT_ZERO_MARKER);
}
$aggregationBuilder
->facet()
->field('results')->pipeline(
$resultsAggregationBuilder
)
->field('count')->pipeline(
$repository->createAggregationBuilder()
->count('count')
);
}
/**
* {@inheritdoc}
*/
public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool
{
if ($context['graphql_operation_name'] ?? false) {
return $this->pagination->isGraphQlEnabled($operation, $context);
}
return $this->pagination->isEnabled($operation, $context);
}
/**
* {@inheritdoc}
*
* @throws RuntimeException
*/
public function getResult(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array $context = []): iterable
{
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
if (!$manager instanceof DocumentManager) {
throw new RuntimeException(\sprintf('The manager for "%s" must be an instance of "%s".', $resourceClass, DocumentManager::class));
}
$attribute = $operation?->getExtraProperties()['doctrine_mongodb'] ?? [];
$executeOptions = $attribute['execute_options'] ?? [];
return new Paginator($aggregationBuilder->execute($executeOptions), $manager->getUnitOfWork(), $resourceClass, $aggregationBuilder->getPipeline());
}
private function addCountToContext(Builder $aggregationBuilder, array $context): array
{
if (!($context['graphql_operation_name'] ?? false)) {
return $context;
}
if (isset($context['filters']['last']) && !isset($context['filters']['before'])) {
$context['count'] = $aggregationBuilder->count('count')->execute()->toArray()[0]['count'];
}
return $context;
}
}

View File

@@ -0,0 +1,75 @@
<?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\Doctrine\Odm\Extension;
use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait;
use ApiPlatform\Doctrine\Odm\Filter\FilterInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ParameterNotFound;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Psr\Container\ContainerInterface;
/**
* Reads operation parameters and execute its filter.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
final class ParameterExtension implements AggregationCollectionExtensionInterface, AggregationItemExtensionInterface
{
use ParameterValueExtractorTrait;
public function __construct(private readonly ContainerInterface $filterLocator)
{
}
private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass = null, ?Operation $operation = null, array &$context = []): void
{
foreach ($operation->getParameters() ?? [] as $parameter) {
if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) {
continue;
}
$values = $this->extractParameterValue($parameter, $v);
if (null === ($filterId = $parameter->getFilter())) {
continue;
}
$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
if ($filter instanceof FilterInterface) {
$filterContext = ['filters' => $values, 'parameter' => $parameter];
$filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext);
// update by reference
if (isset($filterContext['mongodb_odm_sort_fields'])) {
$context['mongodb_odm_sort_fields'] = $filterContext['mongodb_odm_sort_fields'];
}
}
}
}
/**
* {@inheritdoc}
*/
public function applyToCollection(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
$this->applyFilter($aggregationBuilder, $resourceClass, $operation, $context);
}
/**
* {@inheritdoc}
*/
public function applyToItem(Builder $aggregationBuilder, string $resourceClass, array $identifiers, ?Operation $operation = null, array &$context = []): void
{
$this->applyFilter($aggregationBuilder, $resourceClass, $operation, $context);
}
}

View File

@@ -0,0 +1,112 @@
<?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\Doctrine\Odm\Filter;
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
/**
* {@inheritdoc}
*
* Abstract class for easing the implementation of a filter.
*
* @author Alan Poulain <contact@alanpoulain.eu>
*/
abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface
{
use MongoDbOdmPropertyHelperTrait;
use PropertyHelperTrait;
protected LoggerInterface $logger;
public function __construct(protected ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, protected ?array $properties = null, protected ?NameConverterInterface $nameConverter = null)
{
$this->logger = $logger ?? new NullLogger();
}
/**
* {@inheritdoc}
*/
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
foreach ($context['filters'] as $property => $value) {
$this->filterProperty($this->denormalizePropertyName($property), $value, $aggregationBuilder, $resourceClass, $operation, $context);
}
}
/**
* Passes a property through the filter.
*/
abstract protected function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void;
protected function getManagerRegistry(): ManagerRegistry
{
return $this->managerRegistry;
}
protected function getProperties(): ?array
{
return $this->properties;
}
/**
* @param string[] $properties
*/
public function setProperties(array $properties): void
{
$this->properties = $properties;
}
protected function getLogger(): LoggerInterface
{
return $this->logger;
}
/**
* Determines whether the given property is enabled.
*/
protected function isPropertyEnabled(string $property, string $resourceClass): bool
{
if (null === $this->properties) {
// to ensure sanity, nested properties must still be explicitly enabled
return !$this->isPropertyNested($property, $resourceClass);
}
return \array_key_exists($property, $this->properties);
}
protected function denormalizePropertyName(string|int $property): string
{
if (!$this->nameConverter instanceof NameConverterInterface) {
return (string) $property;
}
return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property)));
}
protected function normalizePropertyName(string $property): string
{
if (!$this->nameConverter instanceof NameConverterInterface) {
return $property;
}
return implode('.', array_map($this->nameConverter->normalize(...), explode('.', $property)));
}
}

View File

@@ -0,0 +1,142 @@
<?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\Doctrine\Odm\Filter;
use ApiPlatform\Doctrine\Common\Filter\BooleanFilterTrait;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\Types\Type as MongoDbType;
/**
* The boolean filter allows you to search on boolean fields and values.
*
* Syntax: `?property=<true|false|1|0>`.
*
* <div data-code-selector>
*
* ```php
* <?php
* // api/src/Entity/Book.php
* use ApiPlatform\Metadata\ApiFilter;
* use ApiPlatform\Metadata\ApiResource;
* use ApiPlatform\Doctrine\Odm\Filter\BooleanFilter;
*
* #[ApiResource]
* #[ApiFilter(BooleanFilter::class, properties: ['published'])]
* class Book
* {
* // ...
* }
* ```
*
* ```yaml
* # config/services.yaml
* services:
* book.boolean_filter:
* parent: 'api_platform.doctrine.odm.boolean_filter'
* arguments: [ { published: ~ } ]
* tags: [ 'api_platform.filter' ]
* # The following are mandatory only if a _defaults section is defined with inverted values.
* # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
* autowire: false
* autoconfigure: false
* public: false
*
* # api/config/api_platform/resources.yaml
* resources:
* App\Entity\Book:
* - operations:
* ApiPlatform\Metadata\GetCollection:
* filters: ['book.boolean_filter']
* ```
*
* ```xml
* <!-- api/config/services.xml -->
* <?xml version="1.0" encoding="UTF-8" ?>
* <container
* xmlns="http://symfony.com/schema/dic/services"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="http://symfony.com/schema/dic/services
* https://symfony.com/schema/dic/services/services-1.0.xsd">
* <services>
* <service id="book.boolean_filter" parent="api_platform.doctrine.odm.boolean_filter">
* <argument type="collection">
* <argument key="published"></argument>
* </argument>
* <tag name="api_platform.filter"/>
* </service>
* </services>
* </container>
* <!-- api/config/api_platform/resources.xml -->
* <resources
* xmlns="https://api-platform.com/schema/metadata/resources-3.0"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
* https://api-platform.com/schema/metadata/resources-3.0.xsd">
* <resource class="App\Entity\Book">
* <operations>
* <operation class="ApiPlatform\Metadata\GetCollection">
* <filters>
* <filter>book.boolean_filter</filter>
* </filters>
* </operation>
* </operations>
* </resource>
* </resources>
* ```
*
* </div>
*
* Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?published=true`.
*
* @author Amrouche Hamza <hamza.simperfit@gmail.com>
* @author Teoh Han Hui <teohhanhui@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class BooleanFilter extends AbstractFilter
{
use BooleanFilterTrait;
public const DOCTRINE_BOOLEAN_TYPES = [
MongoDbType::BOOL => true,
MongoDbType::BOOLEAN => true,
];
/**
* {@inheritdoc}
*/
protected function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
if (
!$this->isPropertyEnabled($property, $resourceClass)
|| !$this->isPropertyMapped($property, $resourceClass)
|| !$this->isBooleanField($property, $resourceClass)
) {
return;
}
$value = $this->normalizeValue($value, $property);
if (null === $value) {
return;
}
$matchField = $property;
if ($this->isPropertyNested($property, $resourceClass)) {
[$matchField] = $this->addLookupsForNestedProperty($property, $aggregationBuilder, $resourceClass);
}
$aggregationBuilder->match()->field($matchField)->equals($value);
}
}

View File

@@ -0,0 +1,240 @@
<?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\Doctrine\Odm\Filter;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\Types\Type as MongoDbType;
/**
* The date filter allows to filter a collection by date intervals.
*
* Syntax: `?property[<after|before|strictly_after|strictly_before>]=value`.
*
* The value can take any date format supported by the [`\DateTime` constructor](https://www.php.net/manual/en/datetime.construct.php).
*
* The `after` and `before` filters will filter including the value whereas `strictly_after` and `strictly_before` will filter excluding the value.
*
* The date filter is able to deal with date properties having `null` values. Four behaviors are available at the property level of the filter:
* - Use the default behavior of the DBMS: use `null` strategy
* - Exclude items: use `ApiPlatform\Doctrine\Odm\Filter\DateFilter::EXCLUDE_NULL` (`exclude_null`) strategy
* - Consider items as oldest: use `ApiPlatform\Doctrine\Odm\Filter\DateFilter::INCLUDE_NULL_BEFORE` (`include_null_before`) strategy
* - Consider items as youngest: use `ApiPlatform\Doctrine\Odm\Filter\DateFilter::INCLUDE_NULL_AFTER` (`include_null_after`) strategy
* - Always include items: use `ApiPlatform\Doctrine\Odm\Filter\DateFilter::INCLUDE_NULL_BEFORE_AND_AFTER` (`include_null_before_and_after`) strategy
*
* <div data-code-selector>
*
* ```php
* <?php
* // api/src/Entity/Book.php
* use ApiPlatform\Metadata\ApiFilter;
* use ApiPlatform\Metadata\ApiResource;
* use ApiPlatform\Doctrine\Odm\Filter\DateFilter;
*
* #[ApiResource]
* #[ApiFilter(DateFilter::class, properties: ['createdAt'])]
* class Book
* {
* // ...
* }
* ```
*
* ```yaml
* # config/services.yaml
* services:
* book.date_filter:
* parent: 'api_platform.doctrine.odm.date_filter'
* arguments: [ { createdAt: ~ } ]
* tags: [ 'api_platform.filter' ]
* # The following are mandatory only if a _defaults section is defined with inverted values.
* # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
* autowire: false
* autoconfigure: false
* public: false
*
* # api/config/api_platform/resources.yaml
* resources:
* App\Entity\Book:
* - operations:
* ApiPlatform\Metadata\GetCollection:
* filters: ['book.date_filter']
* ```
*
* ```xml
* <!-- api/config/services.xml -->
* <?xml version="1.0" encoding="UTF-8" ?>
* <container
* xmlns="http://symfony.com/schema/dic/services"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="http://symfony.com/schema/dic/services
* https://symfony.com/schema/dic/services/services-1.0.xsd">
* <services>
* <service id="book.date_filter" parent="api_platform.doctrine.odm.date_filter">
* <argument type="collection">
* <argument key="createdAt"></argument>
* </argument>
* <tag name="api_platform.filter"/>
* </service>
* </services>
* </container>
* <!-- api/config/api_platform/resources.xml -->
* <resources
* xmlns="https://api-platform.com/schema/metadata/resources-3.0"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
* https://api-platform.com/schema/metadata/resources-3.0.xsd">
* <resource class="App\Entity\Book">
* <operations>
* <operation class="ApiPlatform\Metadata\GetCollection">
* <filters>
* <filter>book.date_filter</filter>
* </filters>
* </operation>
* </operations>
* </resource>
* </resources>
* ```
*
* </div>
*
* Given that the collection endpoint is `/books`, you can filter books by date with the following query: `/books?createdAt[after]=2018-03-19`.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Théo FIDRY <theo.fidry@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class DateFilter extends AbstractFilter implements DateFilterInterface
{
use DateFilterTrait;
public const DOCTRINE_DATE_TYPES = [
MongoDbType::DATE => true,
MongoDbType::DATE_IMMUTABLE => true,
];
/**
* {@inheritdoc}
*/
protected function filterProperty(string $property, $values, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
// Expect $values to be an array having the period as keys and the date value as values
if (
!\is_array($values)
|| !$this->isPropertyEnabled($property, $resourceClass)
|| !$this->isPropertyMapped($property, $resourceClass)
|| !$this->isDateField($property, $resourceClass)
) {
return;
}
$matchField = $property;
if ($this->isPropertyNested($property, $resourceClass)) {
[$matchField] = $this->addLookupsForNestedProperty($property, $aggregationBuilder, $resourceClass);
}
$nullManagement = $this->properties[$property] ?? null;
if (self::EXCLUDE_NULL === $nullManagement) {
$aggregationBuilder->match()->field($matchField)->notEqual(null);
}
if (isset($values[self::PARAMETER_BEFORE])) {
$this->addMatch(
$aggregationBuilder,
$matchField,
self::PARAMETER_BEFORE,
$values[self::PARAMETER_BEFORE],
$nullManagement
);
}
if (isset($values[self::PARAMETER_STRICTLY_BEFORE])) {
$this->addMatch(
$aggregationBuilder,
$matchField,
self::PARAMETER_STRICTLY_BEFORE,
$values[self::PARAMETER_STRICTLY_BEFORE],
$nullManagement
);
}
if (isset($values[self::PARAMETER_AFTER])) {
$this->addMatch(
$aggregationBuilder,
$matchField,
self::PARAMETER_AFTER,
$values[self::PARAMETER_AFTER],
$nullManagement
);
}
if (isset($values[self::PARAMETER_STRICTLY_AFTER])) {
$this->addMatch(
$aggregationBuilder,
$matchField,
self::PARAMETER_STRICTLY_AFTER,
$values[self::PARAMETER_STRICTLY_AFTER],
$nullManagement
);
}
}
/**
* Adds the match stage according to the chosen null management.
*/
private function addMatch(Builder $aggregationBuilder, string $field, string $operator, $value, ?string $nullManagement = null): void
{
$value = $this->normalizeValue($value, $operator);
if (null === $value) {
return;
}
try {
$value = new \DateTime($value);
} catch (\Exception) {
// Silently ignore this filter if it can not be transformed to a \DateTime
$this->logger->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('The field "%s" has a wrong date format. Use one accepted by the \DateTime constructor', $field)),
]);
return;
}
$operatorValue = [
self::PARAMETER_BEFORE => '$lte',
self::PARAMETER_STRICTLY_BEFORE => '$lt',
self::PARAMETER_AFTER => '$gte',
self::PARAMETER_STRICTLY_AFTER => '$gt',
];
if ((self::INCLUDE_NULL_BEFORE === $nullManagement && \in_array($operator, [self::PARAMETER_BEFORE, self::PARAMETER_STRICTLY_BEFORE], true))
|| (self::INCLUDE_NULL_AFTER === $nullManagement && \in_array($operator, [self::PARAMETER_AFTER, self::PARAMETER_STRICTLY_AFTER], true))
|| (self::INCLUDE_NULL_BEFORE_AND_AFTER === $nullManagement && \in_array($operator, [self::PARAMETER_AFTER, self::PARAMETER_STRICTLY_AFTER, self::PARAMETER_BEFORE, self::PARAMETER_STRICTLY_BEFORE], true))
) {
$aggregationBuilder->match()->addOr(
$aggregationBuilder->matchExpr()->field($field)->operator($operatorValue[$operator], $value),
$aggregationBuilder->matchExpr()->field($field)->equals(null)
);
return;
}
$aggregationBuilder->match()->addAnd($aggregationBuilder->matchExpr()->field($field)->operator($operatorValue[$operator], $value));
}
}

View File

@@ -0,0 +1,170 @@
<?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\Doctrine\Odm\Filter;
use ApiPlatform\Doctrine\Common\Filter\ExistsFilterInterface;
use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
/**
* The exists filter allows you to select items based on a nullable field value. It will also check the emptiness of a collection association.
*
* Syntax: `?exists[property]=<true|false|1|0>`.
*
* <div data-code-selector>
*
* ```php
* <?php
* // api/src/Entity/Book.php
* use ApiPlatform\Metadata\ApiFilter;
* use ApiPlatform\Metadata\ApiResource;
* use ApiPlatform\Doctrine\Odm\Filter\ExistFilter;
*
* #[ApiResource]
* #[ApiFilter(ExistFilter::class, properties: ['comment'])]
* class Book
* {
* // ...
* }
* ```
*
* ```yaml
* # config/services.yaml
* services:
* book.exist_filter:
* parent: 'api_platform.doctrine.odm.exist_filter'
* arguments: [ { comment: ~ } ]
* tags: [ 'api_platform.filter' ]
* # The following are mandatory only if a _defaults section is defined with inverted values.
* # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
* autowire: false
* autoconfigure: false
* public: false
*
* # api/config/api_platform/resources.yaml
* resources:
* App\Entity\Book:
* - operations:
* ApiPlatform\Metadata\GetCollection:
* filters: ['book.exist_filter']
* ```
*
* ```xml
* <!-- api/config/services.xml -->
* <?xml version="1.0" encoding="UTF-8" ?>
* <container
* xmlns="http://symfony.com/schema/dic/services"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="http://symfony.com/schema/dic/services
* https://symfony.com/schema/dic/services/services-1.0.xsd">
* <services>
* <service id="book.exist_filter" parent="api_platform.doctrine.odm.exist_filter">
* <argument type="collection">
* <argument key="comment"/>
* </argument>
* <tag name="api_platform.filter"/>
* </service>
* </services>
* </container>
* <!-- api/config/api_platform/resources.xml -->
* <resources
* xmlns="https://api-platform.com/schema/metadata/resources-3.0"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
* https://api-platform.com/schema/metadata/resources-3.0.xsd">
* <resource class="App\Entity\Book">
* <operations>
* <operation class="ApiPlatform\Metadata\GetCollection">
* <filters>
* <filter>book.exist_filter</filter>
* </filters>
* </operation>
* </operations>
* </resource>
* </resources>
* ```
*
* </div>
*
* Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?exists[comment]=true`.
*
* @author Teoh Han Hui <teohhanhui@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface
{
use ExistsFilterTrait;
public function __construct(ManagerRegistry $managerRegistry, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null)
{
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
$this->existsParameterName = $existsParameterName;
}
/**
* {@inheritdoc}
*/
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
foreach ($context['filters'][$this->existsParameterName] ?? [] as $property => $value) {
$this->filterProperty($this->denormalizePropertyName($property), $value, $aggregationBuilder, $resourceClass, $operation, $context);
}
}
/**
* {@inheritdoc}
*/
protected function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
if (
!$this->isPropertyEnabled($property, $resourceClass)
|| !$this->isPropertyMapped($property, $resourceClass, true)
|| !$this->isNullableField($property, $resourceClass)
) {
return;
}
$value = $this->normalizeValue($value, $property);
if (null === $value) {
return;
}
$matchField = $property;
if ($this->isPropertyNested($property, $resourceClass)) {
[$matchField] = $this->addLookupsForNestedProperty($property, $aggregationBuilder, $resourceClass);
}
$aggregationBuilder->match()->field($matchField)->{$value ? 'notEqual' : 'equals'}(null);
}
/**
* {@inheritdoc}
*/
protected function isNullableField(string $property, string $resourceClass): bool
{
$propertyParts = $this->splitPropertyParts($property, $resourceClass);
$metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']);
$field = $propertyParts['field'];
return $metadata instanceof ClassMetadata && $metadata->hasField($field) ? $metadata->isNullable($field) : false;
}
}

View File

@@ -0,0 +1,31 @@
<?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\Doctrine\Odm\Filter;
use ApiPlatform\Metadata\FilterInterface as BaseFilterInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
/**
* Doctrine MongoDB ODM filter interface.
*
* @author Alan Poulain <contact@alanpoulain.eu>
*/
interface FilterInterface extends BaseFilterInterface
{
/**
* Applies the filter.
*/
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void;
}

View File

@@ -0,0 +1,166 @@
<?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\Doctrine\Odm\Filter;
use ApiPlatform\Doctrine\Common\Filter\NumericFilterTrait;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\Types\Type as MongoDbType;
/**
* The numeric filter allows you to search on numeric fields and values.
*
* Syntax: `?property=<int|bigint|decimal...>`.
*
* <div data-code-selector>
*
* ```php
* <?php
* // api/src/Entity/Book.php
* use ApiPlatform\Metadata\ApiFilter;
* use ApiPlatform\Metadata\ApiResource;
* use ApiPlatform\Doctrine\Odm\Filter\NumericFilter;
*
* #[ApiResource]
* #[ApiFilter(NumericFilter::class, properties: ['price'])]
* class Book
* {
* // ...
* }
* ```
*
* ```yaml
* # config/services.yaml
* services:
* book.numeric_filter:
* parent: 'api_platform.doctrine.odm.numeric_filter'
* arguments: [ { price: ~ } ]
* tags: [ 'api_platform.filter' ]
* # The following are mandatory only if a _defaults section is defined with inverted values.
* # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
* autowire: false
* autoconfigure: false
* public: false
*
* # api/config/api_platform/resources.yaml
* resources:
* App\Entity\Book:
* - operations:
* ApiPlatform\Metadata\GetCollection:
* filters: ['book.numeric_filter']
* ```
*
* ```xml
* <!-- api/config/services.xml -->
* <?xml version="1.0" encoding="UTF-8" ?>
* <container
* xmlns="http://symfony.com/schema/dic/services"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="http://symfony.com/schema/dic/services
* https://symfony.com/schema/dic/services/services-1.0.xsd">
* <services>
* <service id="book.numeric_filter" parent="api_platform.doctrine.odm.numeric_filter">
* <argument type="collection">
* <argument key="price"/>
* </argument>
* <tag name="api_platform.filter"/>
* </service>
* </services>
* </container>
* <!-- api/config/api_platform/resources.xml -->
* <resources
* xmlns="https://api-platform.com/schema/metadata/resources-3.0"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
* https://api-platform.com/schema/metadata/resources-3.0.xsd">
* <resource class="App\Entity\Book">
* <operations>
* <operation class="ApiPlatform\Metadata\GetCollection">
* <filters>
* <filter>book.numeric_filter</filter>
* </filters>
* </operation>
* </operations>
* </resource>
* </resources>
* ```
*
* </div>
*
* Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?price=10`.
*
* @author Amrouche Hamza <hamza.simperfit@gmail.com>
* @author Teoh Han Hui <teohhanhui@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class NumericFilter extends AbstractFilter
{
use NumericFilterTrait;
/**
* Type of numeric in Doctrine.
*/
public const DOCTRINE_NUMERIC_TYPES = [
MongoDbType::INT => true,
MongoDbType::INTEGER => true,
MongoDbType::FLOAT => true,
];
/**
* {@inheritdoc}
*/
protected function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
if (
!$this->isPropertyEnabled($property, $resourceClass)
|| !$this->isPropertyMapped($property, $resourceClass)
|| !$this->isNumericField($property, $resourceClass)
) {
return;
}
$values = $this->normalizeValues($value, $property);
if (null === $values) {
return;
}
$matchField = $property;
if ($this->isPropertyNested($property, $resourceClass)) {
[$matchField] = $this->addLookupsForNestedProperty($property, $aggregationBuilder, $resourceClass);
}
if (1 === \count($values)) {
$aggregationBuilder->match()->field($matchField)->equals($values[0]);
} else {
$aggregationBuilder->match()->field($matchField)->in($values);
}
}
/**
* {@inheritdoc}
*/
protected function getType(?string $doctrineType = null): string
{
if (null === $doctrineType) {
return 'string';
}
if (MongoDbType::FLOAT === $doctrineType) {
return 'float';
}
return 'int';
}
}

View File

@@ -0,0 +1,267 @@
<?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\Doctrine\Odm\Filter;
use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface;
use ApiPlatform\Doctrine\Common\Filter\OrderFilterTrait;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
/**
* The order filter allows to sort a collection against the given properties.
*
* Syntax: `?order[property]=<asc|desc>`.
*
* <div data-code-selector>
*
* ```php
* <?php
* // api/src/Entity/Book.php
* use ApiPlatform\Metadata\ApiFilter;
* use ApiPlatform\Metadata\ApiResource;
* use ApiPlatform\Doctrine\Odm\Filter\OrderFilter;
*
* #[ApiResource]
* #[ApiFilter(OrderFilter::class, properties: ['id', 'title'], arguments: ['orderParameterName' => 'order'])]
* class Book
* {
* // ...
* }
* ```
*
* ```yaml
* # config/services.yaml
* services:
* book.order_filter:
* parent: 'api_platform.doctrine.odm.order_filter'
* arguments: [ $properties: { id: ~, title: ~ }, $orderParameterName: order ]
* tags: [ 'api_platform.filter' ]
* # The following are mandatory only if a _defaults section is defined with inverted values.
* # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
* autowire: false
* autoconfigure: false
* public: false
*
* # api/config/api_platform/resources.yaml
* resources:
* App\Entity\Book:
* - operations:
* ApiPlatform\Metadata\GetCollection:
* filters: ['book.order_filter']
* ```
*
* ```xml
* <?xml version="1.0" encoding="UTF-8" ?>
* <!-- api/config/services.xml -->
* <?xml version="1.0" encoding="UTF-8" ?>
* <container
* xmlns="http://symfony.com/schema/dic/services"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="http://symfony.com/schema/dic/services
* https://symfony.com/schema/dic/services/services-1.0.xsd">
* <services>
* <service id="book.order_filter" parent="api_platform.doctrine.odm.order_filter">
* <argument type="collection" key="properties">
* <argument key="id"/>
* <argument key="title"/>
* </argument>
* <argument key="orderParameterName">order</argument>
* <tag name="api_platform.filter"/>
* </service>
* </services>
* </container>
* <!-- api/config/api_platform/resources.xml -->
* <resources
* xmlns="https://api-platform.com/schema/metadata/resources-3.0"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
* https://api-platform.com/schema/metadata/resources-3.0.xsd">
* <resource class="App\Entity\Book">
* <operations>
* <operation class="ApiPlatform\Metadata\GetCollection">
* <filters>
* <filter>book.order_filter</filter>
* </filters>
* </operation>
* </operations>
* </resource>
* </resources>
* ```
*
* </div>
*
* Given that the collection endpoint is `/books`, you can filter books by title in ascending order and then by ID in descending order with the following query: `/books?order[title]=desc&order[id]=asc`.
*
* By default, whenever the query does not specify the direction explicitly (e.g.: `/books?order[title]&order[id]`), filters will not be applied unless you configure a default order direction to use:
*
* <div data-code-selector>
*
* ```php
* <?php
* // api/src/Entity/Book.php
* use ApiPlatform\Metadata\ApiFilter;
* use ApiPlatform\Metadata\ApiResource;
* use ApiPlatform\Doctrine\Odm\Filter\OrderFilter;
*
* #[ApiResource]
* #[ApiFilter(OrderFilter::class, properties: ['id' => 'ASC', 'title' => 'DESC'])]
* class Book
* {
* // ...
* }
* ```
*
* ```yaml
* # config/services.yaml
* services:
* book.order_filter:
* parent: 'api_platform.doctrine.odm.order_filter'
* arguments: [ { id: ASC, title: DESC } ]
* tags: [ 'api_platform.filter' ]
* # The following are mandatory only if a _defaults section is defined with inverted values.
* # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
* autowire: false
* autoconfigure: false
* public: false
*
* # api/config/api_platform/resources.yaml
* resources:
* App\Entity\Book:
* - operations:
* ApiPlatform\Metadata\GetCollection:
* filters: ['book.order_filter']
* ```
*
* ```xml
* <?xml version="1.0" encoding="UTF-8" ?>
* <!-- api/config/services.xml -->
* <?xml version="1.0" encoding="UTF-8" ?>
* <container
* xmlns="http://symfony.com/schema/dic/services"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="http://symfony.com/schema/dic/services
* https://symfony.com/schema/dic/services/services-1.0.xsd">
* <services>
* <service id="book.order_filter" parent="api_platform.doctrine.odm.order_filter">
* <argument type="collection">
* <argument key="id">ASC</argument>
* <argument key="title">DESC</argument>
* </argument>
* <tag name="api_platform.filter"/>
* </service>
* </services>
* </container>
* <!-- api/config/api_platform/resources.xml -->
* <resources
* xmlns="https://api-platform.com/schema/metadata/resources-3.0"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
* https://api-platform.com/schema/metadata/resources-3.0.xsd">
* <resource class="App\Entity\Book">
* <operations>
* <operation class="ApiPlatform\Metadata\GetCollection">
* <filters>
* <filter>book.order_filter</filter>
* </filters>
* </operation>
* </operations>
* </resource>
* </resources>
* ```
*
* </div>
*
* When the property used for ordering can contain `null` values, you may want to specify how `null` values are treated in the comparison:
* - Use the default behavior of the DBMS: use `null` strategy
* - Exclude items: use `ApiPlatform\Doctrine\Odm\Filter\OrderFilter::NULLS_SMALLEST` (`nulls_smallest`) strategy
* - Consider items as oldest: use `ApiPlatform\Doctrine\Odm\Filter\OrderFilter::NULLS_LARGEST` (`nulls_largest`) strategy
* - Consider items as youngest: use `ApiPlatform\Doctrine\Odm\Filter\OrderFilter::NULLS_ALWAYS_FIRST` (`nulls_always_first`) strategy
* - Always include items: use `ApiPlatform\Doctrine\Odm\Filter\OrderFilter::NULLS_ALWAYS_LAST` (`nulls_always_last`) strategy
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Théo FIDRY <theo.fidry@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class OrderFilter extends AbstractFilter implements OrderFilterInterface
{
use OrderFilterTrait;
public function __construct(ManagerRegistry $managerRegistry, string $orderParameterName = 'order', ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null)
{
if (null !== $properties) {
$properties = array_map(static function ($propertyOptions) {
// shorthand for default direction
if (\is_string($propertyOptions)) {
$propertyOptions = [
'default_direction' => $propertyOptions,
];
}
return $propertyOptions;
}, $properties);
}
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
$this->orderParameterName = $orderParameterName;
}
/**
* {@inheritdoc}
*/
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
if (isset($context['filters']) && !isset($context['filters'][$this->orderParameterName])) {
return;
}
if (!isset($context['filters'][$this->orderParameterName]) || !\is_array($context['filters'][$this->orderParameterName])) {
parent::apply($aggregationBuilder, $resourceClass, $operation, $context);
return;
}
foreach ($context['filters'][$this->orderParameterName] as $property => $value) {
$this->filterProperty($this->denormalizePropertyName($property), $value, $aggregationBuilder, $resourceClass, $operation, $context);
}
}
/**
* {@inheritdoc}
*/
protected function filterProperty(string $property, $direction, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
if (!$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass)) {
return;
}
$direction = $this->normalizeValue($direction, $property);
if (null === $direction) {
return;
}
$matchField = $property;
if ($this->isPropertyNested($property, $resourceClass)) {
[$matchField] = $this->addLookupsForNestedProperty($property, $aggregationBuilder, $resourceClass, true);
}
$aggregationBuilder->sort(
$context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$matchField => $direction]
);
}
}

View File

@@ -0,0 +1,207 @@
<?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\Doctrine\Odm\Filter;
use ApiPlatform\Doctrine\Common\Filter\RangeFilterInterface;
use ApiPlatform\Doctrine\Common\Filter\RangeFilterTrait;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
/**
* The range filter allows you to filter by a value lower than, greater than, lower than or equal, greater than or equal and between two values.
*
* Syntax: `?property[<lt|gt|lte|gte|between>]=value`.
*
* <div data-code-selector>
*
* ```php
* <?php
* // api/src/Entity/Book.php
* use ApiPlatform\Metadata\ApiFilter;
* use ApiPlatform\Metadata\ApiResource;
* use ApiPlatform\Doctrine\Odm\Filter\RangeFilter;
*
* #[ApiResource]
* #[ApiFilter(RangeFilter::class, properties: ['price'])]
* class Book
* {
* // ...
* }
* ```
*
* ```yaml
* # config/services.yaml
* services:
* book.range_filter:
* parent: 'api_platform.doctrine.odm.range_filter'
* arguments: [ { price: ~ } ]
* tags: [ 'api_platform.filter' ]
* # The following are mandatory only if a _defaults section is defined with inverted values.
* # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
* autowire: false
* autoconfigure: false
* public: false
*
* # api/config/api_platform/resources.yaml
* resources:
* App\Entity\Book:
* - operations:
* ApiPlatform\Metadata\GetCollection:
* filters: ['book.range_filter']
* ```
*
* ```xml
* <?xml version="1.0" encoding="UTF-8" ?>
* <!-- api/config/services.xml -->
* <?xml version="1.0" encoding="UTF-8" ?>
* <container
* xmlns="http://symfony.com/schema/dic/services"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="http://symfony.com/schema/dic/services
* https://symfony.com/schema/dic/services/services-1.0.xsd">
* <services>
* <service id="book.range_filter" parent="api_platform.doctrine.odm.range_filter">
* <argument type="collection">
* <argument key="price"/>
* </argument>
* <tag name="api_platform.filter"/>
* </service>
* </services>
* </container>
* <!-- api/config/api_platform/resources.xml -->
* <resources
* xmlns="https://api-platform.com/schema/metadata/resources-3.0"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
* https://api-platform.com/schema/metadata/resources-3.0.xsd">
* <resource class="App\Entity\Book">
* <operations>
* <operation class="ApiPlatform\Metadata\GetCollection">
* <filters>
* <filter>book.range_filter</filter>
* </filters>
* </operation>
* </operations>
* </resource>
* </resources>
* ```
*
* </div>
*
* Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?price[between]=12.99..15.99`.
*
* @author Lee Siong Chan <ahlee2326@me.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class RangeFilter extends AbstractFilter implements RangeFilterInterface
{
use RangeFilterTrait;
/**
* {@inheritdoc}
*/
protected function filterProperty(string $property, $values, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
if (
!\is_array($values)
|| !$this->isPropertyEnabled($property, $resourceClass)
|| !$this->isPropertyMapped($property, $resourceClass)
) {
return;
}
$values = $this->normalizeValues($values, $property);
if (null === $values) {
return;
}
$matchField = $field = $property;
if ($this->isPropertyNested($property, $resourceClass)) {
[$matchField] = $this->addLookupsForNestedProperty($property, $aggregationBuilder, $resourceClass);
}
foreach ($values as $operator => $value) {
$this->addMatch(
$aggregationBuilder,
$field,
$matchField,
$operator,
$value
);
}
}
/**
* Adds the match stage according to the operator.
*/
protected function addMatch(Builder $aggregationBuilder, string $field, string $matchField, string $operator, string $value): void
{
switch ($operator) {
case self::PARAMETER_BETWEEN:
$rangeValue = explode('..', $value, 2);
$rangeValue = $this->normalizeBetweenValues($rangeValue);
if (null === $rangeValue) {
return;
}
if ($rangeValue[0] === $rangeValue[1]) {
$aggregationBuilder->match()->field($matchField)->equals($rangeValue[0]);
return;
}
$aggregationBuilder->match()->field($matchField)->gte($rangeValue[0])->lte($rangeValue[1]);
break;
case self::PARAMETER_GREATER_THAN:
$value = $this->normalizeValue($value, $operator);
if (null === $value) {
return;
}
$aggregationBuilder->match()->field($matchField)->gt($value);
break;
case self::PARAMETER_GREATER_THAN_OR_EQUAL:
$value = $this->normalizeValue($value, $operator);
if (null === $value) {
return;
}
$aggregationBuilder->match()->field($matchField)->gte($value);
break;
case self::PARAMETER_LESS_THAN:
$value = $this->normalizeValue($value, $operator);
if (null === $value) {
return;
}
$aggregationBuilder->match()->field($matchField)->lt($value);
break;
case self::PARAMETER_LESS_THAN_OR_EQUAL:
$value = $this->normalizeValue($value, $operator);
if (null === $value) {
return;
}
$aggregationBuilder->match()->field($matchField)->lte($value);
break;
}
}
}

View File

@@ -0,0 +1,298 @@
<?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\Doctrine\Odm\Filter;
use ApiPlatform\Api\IdentifiersExtractorInterface as LegacyIdentifiersExtractorInterface;
use ApiPlatform\Api\IriConverterInterface as LegacyIriConverterInterface;
use ApiPlatform\Doctrine\Common\Filter\SearchFilterInterface;
use ApiPlatform\Doctrine\Common\Filter\SearchFilterTrait;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDBClassMetadata;
use Doctrine\ODM\MongoDB\Types\Type as MongoDbType;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\Mapping\ClassMetadata;
use MongoDB\BSON\Regex;
use Psr\Log\LoggerInterface;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
/**
* The search filter allows to filter a collection by given properties.
*
* The search filter supports `exact`, `partial`, `start`, `end`, and `word_start` matching strategies:
* - `exact` strategy searches for fields that exactly match the value
* - `partial` strategy uses `LIKE %value%` to search for fields that contain the value
* - `start` strategy uses `LIKE value%` to search for fields that start with the value
* - `end` strategy uses `LIKE %value` to search for fields that end with the value
* - `word_start` strategy uses `LIKE value% OR LIKE % value%` to search for fields that contain words starting with the value
*
* Note: it is possible to filter on properties and relations too.
*
* Prepend the letter `i` to the filter if you want it to be case-insensitive. For example `ipartial` or `iexact`.
* Note that this will use the `LOWER` function and *will* impact performance if there is no proper index.
*
* Case insensitivity may already be enforced at the database level depending on the [collation](https://en.wikipedia.org/wiki/Collation) used.
* If you are using MySQL, note that the commonly used `utf8_unicode_ci` collation (and its sibling `utf8mb4_unicode_ci`)
* are already case-insensitive, as indicated by the `_ci` part in their names.
*
* Note: Search filters with the `exact` strategy can have multiple values for the same property (in this case the
* condition will be similar to a SQL IN clause).
*
* Syntax: `?property[]=foo&property[]=bar`.
*
* <div data-code-selector>
*
* ```php
* <?php
* // api/src/Entity/Book.php
* use ApiPlatform\Metadata\ApiFilter;
* use ApiPlatform\Metadata\ApiResource;
* use ApiPlatform\Doctrine\Odm\Filter\SearchFilter;
*
* #[ApiResource]
* #[ApiFilter(SearchFilter::class, properties: ['isbn' => 'exact', 'description' => 'partial'])]
* class Book
* {
* // ...
* }
* ```
*
* ```yaml
* # config/services.yaml
* services:
* book.search_filter:
* parent: 'api_platform.doctrine.odm.search_filter'
* arguments: [ { isbn: 'exact', description: 'partial' } ]
* tags: [ 'api_platform.filter' ]
* # The following are mandatory only if a _defaults section is defined with inverted values.
* # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
* autowire: false
* autoconfigure: false
* public: false
*
* # api/config/api_platform/resources.yaml
* resources:
* App\Entity\Book:
* - operations:
* ApiPlatform\Metadata\GetCollection:
* filters: ['book.search_filter']
* ```
*
* ```xml
* <?xml version="1.0" encoding="UTF-8" ?>
* <!-- api/config/services.xml -->
* <?xml version="1.0" encoding="UTF-8" ?>
* <container
* xmlns="http://symfony.com/schema/dic/services"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="http://symfony.com/schema/dic/services
* https://symfony.com/schema/dic/services/services-1.0.xsd">
* <services>
* <service id="book.search_filter" parent="api_platform.doctrine.odm.search_filter">
* <argument type="collection">
* <argument key="isbn">exact</argument>
* <argument key="description">partial</argument>
* </argument>
* <tag name="api_platform.filter"/>
* </service>
* </services>
* </container>
* <!-- api/config/api_platform/resources.xml -->
* <resources
* xmlns="https://api-platform.com/schema/metadata/resources-3.0"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
* https://api-platform.com/schema/metadata/resources-3.0.xsd">
* <resource class="App\Entity\Book">
* <operations>
* <operation class="ApiPlatform\Metadata\GetCollection">
* <filters>
* <filter>book.search_filter</filter>
* </filters>
* </operation>
* </operations>
* </resource>
* </resources>
* ```
*
* </div>
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class SearchFilter extends AbstractFilter implements SearchFilterInterface
{
use SearchFilterTrait;
public const DOCTRINE_INTEGER_TYPE = [MongoDbType::INTEGER, MongoDbType::INT];
public function __construct(ManagerRegistry $managerRegistry, IriConverterInterface|LegacyIriConverterInterface $iriConverter, IdentifiersExtractorInterface|LegacyIdentifiersExtractorInterface|null $identifiersExtractor, ?PropertyAccessorInterface $propertyAccessor = null, ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null)
{
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
$this->iriConverter = $iriConverter;
$this->identifiersExtractor = $identifiersExtractor;
$this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
}
protected function getIriConverter(): LegacyIriConverterInterface|IriConverterInterface
{
return $this->iriConverter;
}
protected function getPropertyAccessor(): PropertyAccessorInterface
{
return $this->propertyAccessor;
}
/**
* {@inheritdoc}
*/
protected function filterProperty(string $property, $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
{
if (
null === $value
|| !$this->isPropertyEnabled($property, $resourceClass)
|| !$this->isPropertyMapped($property, $resourceClass, true)
) {
return;
}
$matchField = $field = $property;
$values = $this->normalizeValues((array) $value, $property);
if (null === $values) {
return;
}
$associations = [];
if ($this->isPropertyNested($property, $resourceClass)) {
[$matchField, $field, $associations] = $this->addLookupsForNestedProperty($property, $aggregationBuilder, $resourceClass);
}
$caseSensitive = true;
$strategy = $this->properties[$property] ?? self::STRATEGY_EXACT;
// prefixing the strategy with i makes it case insensitive
if (str_starts_with($strategy, 'i')) {
$strategy = substr($strategy, 1);
$caseSensitive = false;
}
/** @var MongoDBClassMetadata */
$metadata = $this->getNestedMetadata($resourceClass, $associations);
if ($metadata->hasField($field) && !$metadata->hasAssociation($field)) {
if ('id' === $field) {
$values = array_map($this->getIdFromValue(...), $values);
}
if (!$this->hasValidValues($values, $this->getDoctrineFieldType($property, $resourceClass))) {
$this->logger->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)),
]);
return;
}
$this->addEqualityMatchStrategy($strategy, $aggregationBuilder, $field, $matchField, $values, $caseSensitive, $metadata);
return;
}
// metadata doesn't have the field, nor an association on the field
if (!$metadata->hasAssociation($field)) {
return;
}
$values = array_map($this->getIdFromValue(...), $values);
$associationResourceClass = $metadata->getAssociationTargetClass($field);
$associationFieldIdentifier = $metadata->getIdentifierFieldNames()[0];
$doctrineTypeField = $this->getDoctrineFieldType($associationFieldIdentifier, $associationResourceClass);
if (!$this->hasValidValues($values, $doctrineTypeField)) {
$this->logger->notice('Invalid filter ignored', [
'exception' => new InvalidArgumentException(\sprintf('Values for field "%s" are not valid according to the doctrine type.', $property)),
]);
return;
}
$this->addEqualityMatchStrategy($strategy, $aggregationBuilder, $field, $matchField, $values, $caseSensitive, $metadata);
}
/**
* Add equality match stage according to the strategy.
*/
private function addEqualityMatchStrategy(string $strategy, Builder $aggregationBuilder, string $field, string $matchField, array $values, bool $caseSensitive, ClassMetadata $metadata): void
{
$inValues = [];
foreach ($values as $inValue) {
$inValues[] = $this->getEqualityMatchStrategyValue($strategy, $field, $inValue, $caseSensitive, $metadata);
}
$aggregationBuilder
->match()
->field($matchField)
->in($inValues);
}
/**
* Get equality match value according to the strategy.
*
* @throws InvalidArgumentException If strategy does not exist
*/
private function getEqualityMatchStrategyValue(string $strategy, string $field, mixed $value, bool $caseSensitive, ClassMetadata $metadata): mixed
{
$type = $metadata->getTypeOfField($field);
if (!MongoDbType::hasType($type)) {
return $value;
}
if (MongoDbType::STRING !== $type) {
return MongoDbType::getType($type)->convertToDatabaseValue($value);
}
$quotedValue = preg_quote($value);
return match ($strategy) {
self::STRATEGY_EXACT => $caseSensitive ? $value : new Regex("^$quotedValue$", 'i'),
self::STRATEGY_PARTIAL => new Regex($quotedValue, $caseSensitive ? '' : 'i'),
self::STRATEGY_START => new Regex("^$quotedValue", $caseSensitive ? '' : 'i'),
self::STRATEGY_END => new Regex("$quotedValue$", $caseSensitive ? '' : 'i'),
self::STRATEGY_WORD_START => new Regex("(^$quotedValue.*|.*\s$quotedValue.*)", $caseSensitive ? '' : 'i'),
default => throw new InvalidArgumentException(\sprintf('strategy %s does not exist.', $strategy)),
};
}
/**
* {@inheritdoc}
*/
protected function getType(string $doctrineType): string
{
return match ($doctrineType) {
MongoDbType::INT, MongoDbType::INTEGER => 'int',
MongoDbType::BOOL, MongoDbType::BOOLEAN => 'bool',
MongoDbType::DATE, MongoDbType::DATE_IMMUTABLE => \DateTimeInterface::class,
MongoDbType::FLOAT => 'float',
default => 'string',
};
}
}

View File

@@ -0,0 +1,21 @@
The MIT license
Copyright (c) 2015-present Kévin Dunglas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,72 @@
<?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\Doctrine\Odm\Metadata\Property;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\Persistence\ManagerRegistry;
/**
* Use Doctrine metadata to populate the identifier property.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class DoctrineMongoDbOdmPropertyMetadataFactory implements PropertyMetadataFactoryInterface
{
public function __construct(private readonly ManagerRegistry $managerRegistry, private readonly PropertyMetadataFactoryInterface $decorated)
{
}
/**
* {@inheritdoc}
*/
public function create(string $resourceClass, string $property, array $options = []): ApiProperty
{
$propertyMetadata = $this->decorated->create($resourceClass, $property, $options);
if (null !== $propertyMetadata->isIdentifier()) {
return $propertyMetadata;
}
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
if (!$manager instanceof DocumentManager) {
return $propertyMetadata;
}
$doctrineClassMetadata = $manager->getClassMetadata($resourceClass);
$identifiers = $doctrineClassMetadata->getIdentifier();
foreach ($identifiers as $identifier) {
if ($identifier === $property) {
$propertyMetadata = $propertyMetadata->withIdentifier(true);
if (null !== $propertyMetadata->isWritable()) {
break;
}
$propertyMetadata = $propertyMetadata->withWritable(false);
break;
}
}
if (null === $propertyMetadata->isIdentifier()) {
$propertyMetadata = $propertyMetadata->withIdentifier(false);
}
return $propertyMetadata;
}
}

View File

@@ -0,0 +1,119 @@
<?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\Doctrine\Odm\Metadata\Resource;
use ApiPlatform\Doctrine\Odm\State\CollectionProvider;
use ApiPlatform\Doctrine\Odm\State\ItemProvider;
use ApiPlatform\Doctrine\Odm\State\Options;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\Persistence\ManagerRegistry;
final class DoctrineMongoDbOdmResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface
{
public function __construct(private readonly ManagerRegistry $managerRegistry, private readonly ResourceMetadataCollectionFactoryInterface $decorated)
{
}
/**
* {@inheritdoc}
*/
public function create(string $resourceClass): ResourceMetadataCollection
{
$resourceMetadataCollection = $this->decorated->create($resourceClass);
/** @var ApiResource $resourceMetadata */
foreach ($resourceMetadataCollection as $i => $resourceMetadata) {
$operations = $resourceMetadata->getOperations();
if ($operations) {
/** @var Operation $operation */
foreach ($resourceMetadata->getOperations() as $operationName => $operation) {
$documentClass = $operation->getClass();
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getDocumentClass()) {
$documentClass = $options->getDocumentClass();
}
if (!$this->managerRegistry->getManagerForClass($documentClass) instanceof DocumentManager) {
continue;
}
$operations->add($operationName, $this->addDefaults($operation));
}
$resourceMetadata = $resourceMetadata->withOperations($operations);
}
$graphQlOperations = $resourceMetadata->getGraphQlOperations();
if ($graphQlOperations) {
foreach ($graphQlOperations as $operationName => $graphQlOperation) {
if (!$this->managerRegistry->getManagerForClass($graphQlOperation->getClass()) instanceof DocumentManager) {
continue;
}
$graphQlOperations[$operationName] = $this->addDefaults($graphQlOperation);
}
$resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations);
}
$resourceMetadataCollection[$i] = $resourceMetadata;
}
return $resourceMetadataCollection;
}
private function addDefaults(Operation $operation): Operation
{
$options = $operation->getStateOptions() ?: new Options();
if ($options instanceof Options && null === $options->getHandleLinks()) {
$options = $options->withHandleLinks('api_platform.doctrine.odm.links_handler');
$operation = $operation->withStateOptions($options);
}
if (null === $operation->getProvider()) {
$operation = $operation->withProvider($this->getProvider($operation));
}
if (null === $operation->getProcessor()) {
$operation = $operation->withProcessor($this->getProcessor($operation));
}
return $operation;
}
private function getProvider(Operation $operation): string
{
if ($operation instanceof CollectionOperationInterface) {
return CollectionProvider::class;
}
return ItemProvider::class;
}
private function getProcessor(Operation $operation): string
{
if ($operation instanceof DeleteOperationInterface) {
return 'api_platform.doctrine_mongodb.odm.state.remove_processor';
}
return 'api_platform.doctrine_mongodb.odm.state.persist_processor';
}
}

View File

@@ -0,0 +1,161 @@
<?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\Doctrine\Odm;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface;
use ApiPlatform\State\Pagination\PaginatorInterface;
use Doctrine\ODM\MongoDB\Iterator\Iterator;
use Doctrine\ODM\MongoDB\UnitOfWork;
/**
* Decorates the Doctrine MongoDB ODM paginator.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class Paginator implements \IteratorAggregate, PaginatorInterface, HasNextPagePaginatorInterface
{
public const LIMIT_ZERO_MARKER_FIELD = '___';
public const LIMIT_ZERO_MARKER = 'limit0';
private ?\ArrayIterator $iterator = null;
private readonly int $firstResult;
private readonly int $maxResults;
private readonly int $totalItems;
public function __construct(private readonly Iterator $mongoDbOdmIterator, private readonly UnitOfWork $unitOfWork, private readonly string $resourceClass, private readonly array $pipeline)
{
$resultsFacetInfo = $this->getFacetInfo('results');
$this->getFacetInfo('count');
/*
* Since the {@see \MongoDB\Driver\Cursor} class does not expose information about
* skip/limit parameters of the query, the values set in the facet stage are used instead.
*/
$this->firstResult = $this->getStageInfo($resultsFacetInfo, '$skip');
$this->maxResults = $this->hasLimitZeroStage($resultsFacetInfo) ? 0 : $this->getStageInfo($resultsFacetInfo, '$limit');
$this->totalItems = $mongoDbOdmIterator->toArray()[0]['count'][0]['count'] ?? 0;
}
/**
* {@inheritdoc}
*/
public function getCurrentPage(): float
{
if (0 >= $this->maxResults) {
return 1.;
}
return floor($this->firstResult / $this->maxResults) + 1.;
}
/**
* {@inheritdoc}
*/
public function getLastPage(): float
{
if (0 >= $this->maxResults) {
return 1.;
}
return ceil($this->totalItems / $this->maxResults) ?: 1.;
}
/**
* {@inheritdoc}
*/
public function getItemsPerPage(): float
{
return (float) $this->maxResults;
}
/**
* {@inheritdoc}
*/
public function getTotalItems(): float
{
return (float) $this->totalItems;
}
/**
* {@inheritdoc}
*/
public function getIterator(): \Traversable
{
return $this->iterator ?? $this->iterator = new \ArrayIterator(array_map(fn ($result): object => $this->unitOfWork->getOrCreateDocument($this->resourceClass, $result), $this->mongoDbOdmIterator->toArray()[0]['results']));
}
/**
* {@inheritdoc}
*/
public function count(): int
{
return is_countable($this->mongoDbOdmIterator->toArray()[0]['results']) ? \count($this->mongoDbOdmIterator->toArray()[0]['results']) : 0;
}
/**
* {@inheritdoc}
*/
public function hasNextPage(): bool
{
return $this->getLastPage() > $this->getCurrentPage();
}
/**
* @throws InvalidArgumentException
*/
private function getFacetInfo(string $field): array
{
foreach ($this->pipeline as $indexStage => $infoStage) {
if (\array_key_exists('$facet', $infoStage)) {
if (!isset($this->pipeline[$indexStage]['$facet'][$field])) {
throw new InvalidArgumentException("\"$field\" facet was not applied to the aggregation pipeline.");
}
return $this->pipeline[$indexStage]['$facet'][$field];
}
}
throw new InvalidArgumentException('$facet stage was not applied to the aggregation pipeline.');
}
/**
* @throws InvalidArgumentException
*/
private function getStageInfo(array $resultsFacetInfo, string $stage): int
{
foreach ($resultsFacetInfo as $resultFacetInfo) {
if (isset($resultFacetInfo[$stage])) {
return $resultFacetInfo[$stage];
}
}
throw new InvalidArgumentException("$stage stage was not applied to the facet stage of the aggregation pipeline.");
}
private function hasLimitZeroStage(array $resultsFacetInfo): bool
{
foreach ($resultsFacetInfo as $resultFacetInfo) {
if (self::LIMIT_ZERO_MARKER === ($resultFacetInfo['$match'][self::LIMIT_ZERO_MARKER_FIELD] ?? null)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,120 @@
<?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\Doctrine\Odm;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDbOdmClassMetadata;
use Doctrine\ODM\MongoDB\Mapping\MappingException;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\Mapping\ClassMetadata;
/**
* Helper trait regarding a property in a MongoDB document using the resource metadata.
*
* @author Alan Poulain <contact@alanpoulain.eu>
*/
trait PropertyHelperTrait
{
abstract protected function getManagerRegistry(): ManagerRegistry;
/**
* Splits the given property into parts.
*/
abstract protected function splitPropertyParts(string $property, string $resourceClass): array;
/**
* Gets class metadata for the given resource.
*/
protected function getClassMetadata(string $resourceClass): ClassMetadata
{
$manager = $this
->getManagerRegistry()
->getManagerForClass($resourceClass);
if ($manager) {
return $manager->getClassMetadata($resourceClass);
}
return new MongoDbOdmClassMetadata($resourceClass);
}
/**
* Adds the necessary lookups for a nested property.
*
* @throws InvalidArgumentException If property is not nested
* @throws MappingException
*
* @return array An array where the first element is the $alias of the lookup,
* the second element is the $field name
* the third element is the $associations array
*/
protected function addLookupsForNestedProperty(string $property, Builder $aggregationBuilder, string $resourceClass, bool $preserveNullAndEmptyArrays = false): array
{
$propertyParts = $this->splitPropertyParts($property, $resourceClass);
$alias = '';
foreach ($propertyParts['associations'] as $association) {
$classMetadata = $this->getClassMetadata($resourceClass);
if (!$classMetadata instanceof MongoDbOdmClassMetadata) {
break;
}
if ($classMetadata->hasReference($association)) {
$propertyAlias = "{$association}_lkup";
// previous_association_lkup.association
$localField = "$alias$association";
// previous_association_lkup.association_lkup
$alias .= $propertyAlias;
$referenceMapping = $classMetadata->getFieldMapping($association);
if (($isOwningSide = $referenceMapping['isOwningSide']) && MongoDbOdmClassMetadata::REFERENCE_STORE_AS_ID !== $referenceMapping['storeAs']) {
throw MappingException::cannotLookupDbRefReference($classMetadata->getReflectionClass()->getShortName(), $association);
}
if (!$isOwningSide) {
if (isset($referenceMapping['repositoryMethod']) || !isset($referenceMapping['mappedBy'])) {
throw MappingException::repositoryMethodLookupNotAllowed($classMetadata->getReflectionClass()->getShortName(), $association);
}
$targetClassMetadata = $this->getClassMetadata($referenceMapping['targetDocument']);
if ($targetClassMetadata instanceof MongoDbOdmClassMetadata && MongoDbOdmClassMetadata::REFERENCE_STORE_AS_ID !== $targetClassMetadata->getFieldMapping($referenceMapping['mappedBy'])['storeAs']) {
throw MappingException::cannotLookupDbRefReference($classMetadata->getReflectionClass()->getShortName(), $association);
}
}
$aggregationBuilder->lookup($classMetadata->getAssociationTargetClass($association))
->localField($isOwningSide ? $localField : '_id')
->foreignField($isOwningSide ? '_id' : $referenceMapping['mappedBy'])
->alias($alias);
$aggregationBuilder->unwind("\$$alias")
->preserveNullAndEmptyArrays($preserveNullAndEmptyArrays);
// association.property => association_lkup.property
$property = substr_replace($property, $propertyAlias, strpos($property, (string) $association), \strlen((string) $association));
$resourceClass = $classMetadata->getAssociationTargetClass($association);
$alias .= '.';
} elseif ($classMetadata->hasEmbed($association)) {
$alias = "$association.";
$resourceClass = $classMetadata->getAssociationTargetClass($association);
}
}
if ('' === $alias) {
throw new InvalidArgumentException(\sprintf('Cannot add lookups for property "%s" - property is not nested.', $property));
}
return [$property, $propertyParts['field'], $propertyParts['associations']];
}
}

View File

@@ -0,0 +1,177 @@
<?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\Doctrine\Odm\PropertyInfo;
use ApiPlatform\Metadata\Util\PropertyInfoToTypeInfoHelper;
use Doctrine\Common\Collections\Collection;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDbClassMetadata;
use Doctrine\ODM\MongoDB\Types\Type as MongoDbType;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Doctrine\Persistence\Mapping\MappingException;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyListExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\PropertyInfo\Type as LegacyType;
use Symfony\Component\TypeInfo\Type;
/**
* Extracts data using Doctrine MongoDB ODM metadata.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class DoctrineExtractor implements PropertyListExtractorInterface, PropertyTypeExtractorInterface, PropertyAccessExtractorInterface
{
public function __construct(private readonly ObjectManager $objectManager)
{
}
/**
* {@inheritdoc}
*
* @return string[]|null
*/
public function getProperties($class, array $context = []): ?array
{
if (null === $metadata = $this->getMetadata($class)) {
return null;
}
return $metadata->getFieldNames();
}
/**
* {@inheritdoc}
*
* @return LegacyType[]|null
*/
public function getTypes(string $class, string $property, array $context = []): ?array
{
if (null === $metadata = $this->getMetadata($class)) {
return null;
}
if ($metadata->hasAssociation($property)) {
/** @var class-string|null */
$class = $metadata->getAssociationTargetClass($property);
if (null === $class) {
return null;
}
if ($metadata->isSingleValuedAssociation($property)) {
$nullable = $metadata instanceof MongoDbClassMetadata && $metadata->isNullable($property);
return [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $nullable, $class)];
}
$collectionKeyType = LegacyType::BUILTIN_TYPE_INT;
return [
new LegacyType(
LegacyType::BUILTIN_TYPE_OBJECT,
false,
Collection::class,
true,
new LegacyType($collectionKeyType),
new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, $class)
),
];
}
if ($metadata->hasField($property)) {
$typeOfField = $metadata->getTypeOfField($property);
$nullable = $metadata instanceof MongoDbClassMetadata && $metadata->isNullable($property);
$enumType = null;
if (null !== $enumClass = $metadata instanceof MongoDbClassMetadata ? $metadata->getFieldMapping($property)['enumType'] ?? null : null) {
$enumType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $nullable, $enumClass);
}
switch ($typeOfField) {
case MongoDbType::DATE:
return [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $nullable, \DateTime::class)];
case MongoDbType::DATE_IMMUTABLE:
return [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $nullable, \DateTimeImmutable::class)];
case MongoDbType::HASH:
return [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, $nullable, null, true)];
case MongoDbType::COLLECTION:
return [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, $nullable, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT))];
case MongoDbType::INT:
case MongoDbType::STRING:
if ($enumType) {
return [$enumType];
}
}
$builtinType = $this->getPhpType($typeOfField);
return $builtinType ? [new LegacyType($builtinType, $nullable)] : null;
}
return null;
}
/**
* {@inheritdoc}
*/
public function isReadable($class, $property, array $context = []): ?bool
{
return null;
}
/**
* {@inheritdoc}
*/
public function isWritable($class, $property, array $context = []): ?bool
{
if (
null === ($metadata = $this->getMetadata($class))
|| ($metadata instanceof MongoDbClassMetadata && MongoDbClassMetadata::GENERATOR_TYPE_NONE === $metadata->generatorType)
|| !\in_array($property, $metadata->getIdentifierFieldNames(), true)
) {
return null;
}
return false;
}
private function getMetadata(string $class): ?ClassMetadata
{
try {
return $this->objectManager->getClassMetadata($class);
} catch (MappingException) {
return null;
}
}
public function getType(string $class, string $property, array $context = []): ?Type
{
return PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($this->getTypes($class, $property, $context));
}
/**
* Gets the corresponding built-in PHP type.
*/
private function getPhpType(string $doctrineType): ?string
{
return match ($doctrineType) {
MongoDbType::INTEGER, MongoDbType::INT, MongoDbType::INTID, MongoDbType::KEY => LegacyType::BUILTIN_TYPE_INT,
MongoDbType::FLOAT => LegacyType::BUILTIN_TYPE_FLOAT,
MongoDbType::STRING, MongoDbType::ID, MongoDbType::OBJECTID, MongoDbType::TIMESTAMP, MongoDbType::BINDATA, MongoDbType::BINDATABYTEARRAY, MongoDbType::BINDATACUSTOM, MongoDbType::BINDATAFUNC, MongoDbType::BINDATAMD5, MongoDbType::BINDATAUUID, MongoDbType::BINDATAUUIDRFC4122 => LegacyType::BUILTIN_TYPE_STRING,
MongoDbType::BOOLEAN, MongoDbType::BOOL => LegacyType::BUILTIN_TYPE_BOOL,
default => null,
};
}
}

View File

@@ -0,0 +1,82 @@
<?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\Doctrine\Odm\State;
use ApiPlatform\Doctrine\Common\State\LinksHandlerLocatorTrait;
use ApiPlatform\Doctrine\Odm\Extension\AggregationCollectionExtensionInterface;
use ApiPlatform\Doctrine\Odm\Extension\AggregationResultCollectionExtensionInterface;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\ProviderInterface;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Container\ContainerInterface;
/**
* Collection state provider using the Doctrine ODM.
*/
final class CollectionProvider implements ProviderInterface
{
use LinksHandlerLocatorTrait;
use LinksHandlerTrait;
/**
* @param AggregationCollectionExtensionInterface[] $collectionExtensions
*/
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [], ?ContainerInterface $handleLinksLocator = null)
{
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
$this->handleLinksLocator = $handleLinksLocator;
$this->managerRegistry = $managerRegistry;
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
{
$documentClass = $operation->getClass();
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getDocumentClass()) {
$documentClass = $options->getDocumentClass();
}
/** @var DocumentManager $manager */
$manager = $this->managerRegistry->getManagerForClass($documentClass);
$repository = $manager->getRepository($documentClass);
if (!$repository instanceof DocumentRepository) {
throw new RuntimeException(\sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class));
}
$aggregationBuilder = $repository->createAggregationBuilder();
if ($handleLinks = $this->getLinksHandler($operation)) {
$handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context);
} else {
$this->handleLinks($aggregationBuilder, $uriVariables, $context, $documentClass, $operation);
}
foreach ($this->collectionExtensions as $extension) {
$extension->applyToCollection($aggregationBuilder, $documentClass, $operation, $context);
if ($extension instanceof AggregationResultCollectionExtensionInterface && $extension->supportsResult($documentClass, $operation, $context)) {
return $extension->getResult($aggregationBuilder, $documentClass, $operation, $context);
}
}
$attribute = $operation->getExtraProperties()['doctrine_mongodb'] ?? [];
$executeOptions = $attribute['execute_options'] ?? [];
return $aggregationBuilder->hydrate($documentClass)->execute($executeOptions);
}
}

View File

@@ -0,0 +1,89 @@
<?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\Doctrine\Odm\State;
use ApiPlatform\Doctrine\Common\State\LinksHandlerLocatorTrait;
use ApiPlatform\Doctrine\Odm\Extension\AggregationItemExtensionInterface;
use ApiPlatform\Doctrine\Odm\Extension\AggregationResultItemExtensionInterface;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\ProviderInterface;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Container\ContainerInterface;
/**
* Item state provider using the Doctrine ODM.
*
* @author Kévin Dunglas <kevin@dunglas.fr>
* @author Samuel ROZE <samuel.roze@gmail.com>
*/
final class ItemProvider implements ProviderInterface
{
use LinksHandlerLocatorTrait;
use LinksHandlerTrait;
/**
* @param AggregationItemExtensionInterface[] $itemExtensions
*/
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [], ?ContainerInterface $handleLinksLocator = null)
{
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
$this->handleLinksLocator = $handleLinksLocator;
$this->managerRegistry = $managerRegistry;
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
{
$documentClass = $operation->getClass();
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getDocumentClass()) {
$documentClass = $options->getDocumentClass();
}
/** @var DocumentManager $manager */
$manager = $this->managerRegistry->getManagerForClass($documentClass);
$fetchData = $context['fetch_data'] ?? true;
if (!$fetchData) {
return $manager->getReference($documentClass, reset($uriVariables));
}
$repository = $manager->getRepository($documentClass);
if (!$repository instanceof DocumentRepository) {
throw new RuntimeException(\sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class));
}
$aggregationBuilder = $repository->createAggregationBuilder();
if ($handleLinks = $this->getLinksHandler($operation)) {
$handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context);
} else {
$this->handleLinks($aggregationBuilder, $uriVariables, $context, $documentClass, $operation);
}
foreach ($this->itemExtensions as $extension) {
$extension->applyToItem($aggregationBuilder, $documentClass, $uriVariables, $operation, $context);
if ($extension instanceof AggregationResultItemExtensionInterface && $extension->supportsResult($documentClass, $operation, $context)) {
return $extension->getResult($aggregationBuilder, $documentClass, $operation, $context);
}
}
$executeOptions = $operation->getExtraProperties()['doctrine_mongodb']['execute_options'] ?? [];
return $aggregationBuilder->hydrate($documentClass)->execute($executeOptions)->current() ?: null;
}
}

View File

@@ -0,0 +1,36 @@
<?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\Doctrine\Odm\State;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\Persistence\ManagerRegistry;
final class LinksHandler implements LinksHandlerInterface
{
use LinksHandlerTrait {
handleLinks as private handle;
}
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry)
{
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
$this->managerRegistry = $managerRegistry;
}
public function handleLinks(Builder $aggregationBuilder, array $uriVariables, array $context): void
{
$this->handle($aggregationBuilder, $uriVariables, $context, $context['documentClass'], $context['operation']);
}
}

View File

@@ -0,0 +1,33 @@
<?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\Doctrine\Odm\State;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
/**
* @experimental
*/
interface LinksHandlerInterface
{
/**
* Handle Doctrine ORM links.
*
* @see LinksHandlerTrait
*
* @param array<string, mixed> $uriVariables
* @param array{documentClass: string, operation: Operation}&array<string, mixed> $context
*/
public function handleLinks(Builder $aggregationBuilder, array $uriVariables, array $context): void;
}

View File

@@ -0,0 +1,167 @@
<?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\Doctrine\Odm\State;
use ApiPlatform\Doctrine\Common\State\LinksHandlerTrait as CommonLinksHandlerTrait;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Operation;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
use Doctrine\Persistence\ManagerRegistry;
/**
* @internal
*/
trait LinksHandlerTrait
{
use CommonLinksHandlerTrait;
private ManagerRegistry $managerRegistry;
private function handleLinks(Builder $aggregationBuilder, array $identifiers, array $context, string $resourceClass, ?Operation $operation = null): void
{
if (!$identifiers) {
return;
}
$links = $this->getLinks($resourceClass, $operation, $context);
if (!$links) {
return;
}
foreach ($links as $i => $link) {
if (null !== $link->getExpandedValue()) {
unset($links[$i]);
}
}
$executeOptions = $operation->getExtraProperties()['doctrine_mongodb']['execute_options'] ?? [];
$this->buildAggregation($resourceClass, array_reverse($links), array_reverse($identifiers), $context, $executeOptions, $resourceClass, $aggregationBuilder, $operation);
}
/**
* @throws RuntimeException
*/
private function buildAggregation(string $toClass, array $links, array $identifiers, array $context, array $executeOptions, string $previousAggregationClass, Builder $previousAggregationBuilder, ?Operation $operation = null): Builder
{
if (!$operation) {
trigger_deprecation('api-platform/core', '3.2', 'In API Platform 4 the last argument "operation" will be required and this trait will be internal. Use the "handleLinks" feature instead.');
}
if (\count($links) <= 0) {
return $previousAggregationBuilder;
}
/** @var Link $link */
$link = array_shift($links);
$fromClass = $link->getFromClass();
$fromProperty = $link->getFromProperty();
$toProperty = $link->getToProperty();
$identifierProperties = $link->getIdentifiers();
$hasCompositeIdentifiers = 1 < \count($identifierProperties);
$aggregationClass = $fromClass;
if ($toProperty) {
$aggregationClass = $toClass;
}
$lookupProperty = $toProperty ?? $fromProperty;
$lookupPropertyAlias = $lookupProperty ? "{$lookupProperty}_lkup" : null;
$manager = $this->managerRegistry->getManagerForClass($aggregationClass);
if (!$manager instanceof DocumentManager) {
if ($operation) {
$aggregationClass = $this->getLinkFromClass($link, $operation);
$manager = $this->managerRegistry->getManagerForClass($aggregationClass);
}
if (!$manager instanceof DocumentManager) {
throw new RuntimeException(\sprintf('The manager for "%s" must be an instance of "%s".', $aggregationClass, DocumentManager::class));
}
}
$classMetadata = $manager->getClassMetadata($aggregationClass);
if (!$classMetadata instanceof ClassMetadata) {
throw new RuntimeException(\sprintf('The class metadata for "%s" must be an instance of "%s".', $aggregationClass, ClassMetadata::class));
}
$aggregation = $previousAggregationBuilder;
if ($aggregationClass !== $previousAggregationClass) {
$aggregation = $manager->createAggregationBuilder($aggregationClass);
}
if ($lookupProperty && $classMetadata->hasAssociation($lookupProperty)) {
$aggregation->lookup($lookupProperty)->alias($lookupPropertyAlias);
}
if ($toProperty) {
foreach ($identifierProperties as $identifierProperty) {
$aggregation->match()->field(\sprintf('%s.%s', $lookupPropertyAlias, 'id' === $identifierProperty ? '_id' : $identifierProperty))->equals($this->getIdentifierValue($identifiers, $hasCompositeIdentifiers ? $identifierProperty : null));
}
} else {
foreach ($identifierProperties as $identifierProperty) {
$aggregation->match()->field($identifierProperty)->equals($this->getIdentifierValue($identifiers, $hasCompositeIdentifiers ? $identifierProperty : null));
}
}
// Recurse aggregations
$aggregation = $this->buildAggregation($fromClass, $links, $identifiers, $context, $executeOptions, $aggregationClass, $aggregation, $operation);
if (null === $fromProperty || null !== $toProperty) {
return $aggregation;
}
$results = $aggregation->execute($executeOptions)->toArray();
$in = [];
foreach ($results as $result) {
foreach ($result[$lookupPropertyAlias] ?? [] as $lookupResult) {
$in[] = $lookupResult['_id'];
}
}
$previousAggregationBuilder->match()->field('_id')->in($in);
return $previousAggregationBuilder;
}
private function getLinkFromClass(Link $link, Operation $operation): string
{
$fromClass = $link->getFromClass();
if ($fromClass === $operation->getClass() && $documentClass = $this->getStateOptionsDocumentClass($operation)) {
return $documentClass;
}
$operation = $this->resourceMetadataCollectionFactory->create($fromClass)->getOperation();
if ($documentClass = $this->getStateOptionsDocumentClass($operation)) {
return $documentClass;
}
throw new \Exception('Can not found a doctrine class for this link.');
}
private function getStateOptionsDocumentClass(Operation $operation): ?string
{
if (($options = $operation->getStateOptions()) && $options instanceof Options && $documentClass = $options->getDocumentClass()) {
return $documentClass;
}
return null;
}
}

View File

@@ -0,0 +1,45 @@
<?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\Doctrine\Odm\State;
use ApiPlatform\Doctrine\Common\State\Options as CommonOptions;
use ApiPlatform\State\OptionsInterface;
class Options extends CommonOptions implements OptionsInterface
{
/**
* @param mixed $handleLinks experimental callable, typed mixed as we may want a service name in the future
*
* @see LinksHandlerInterface
*/
public function __construct(
protected ?string $documentClass = null,
mixed $handleLinks = null,
) {
parent::__construct(handleLinks: $handleLinks);
}
public function getDocumentClass(): ?string
{
return $this->documentClass;
}
public function withDocumentClass(?string $documentClass): self
{
$self = clone $this;
$self->documentClass = $documentClass;
return $self;
}
}

View File

@@ -0,0 +1,79 @@
<?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\Doctrine\Orm;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\State\Pagination\PartialPaginatorInterface;
use Doctrine\ORM\Query;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
abstract class AbstractPaginator implements \IteratorAggregate, PartialPaginatorInterface
{
protected DoctrinePaginator $paginator;
protected array|\Traversable $iterator;
protected ?int $firstResult;
protected ?int $maxResults;
/**
* @throws InvalidArgumentException
*/
public function __construct(DoctrinePaginator $paginator)
{
$query = $paginator->getQuery();
if (null === ($firstResult = $query->getFirstResult()) || $firstResult < 0 || null === $maxResults = $query->getMaxResults()) { // @phpstan-ignore-line
throw new InvalidArgumentException(\sprintf('"%1$s::setFirstResult()" or/and "%1$s::setMaxResults()" was/were not applied to the query.', Query::class));
}
$this->paginator = $paginator;
$this->firstResult = $firstResult;
$this->maxResults = $maxResults;
}
/**
* {@inheritdoc}
*/
public function getCurrentPage(): float
{
if (0 >= $this->maxResults) {
return 1.;
}
return floor($this->firstResult / $this->maxResults) + 1.;
}
/**
* {@inheritdoc}
*/
public function getItemsPerPage(): float
{
return (float) $this->maxResults;
}
/**
* {@inheritdoc}
*/
public function getIterator(): \Traversable
{
return $this->iterator ?? $this->iterator = $this->paginator->getIterator();
}
/**
* {@inheritdoc}
*/
public function count(): int
{
return iterator_count($this->getIterator());
}
}

View File

@@ -0,0 +1,24 @@
<?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\Doctrine\Orm\Extension;
use Doctrine\ORM\Tools\Pagination\Paginator;
class DoctrinePaginatorFactory
{
public function getPaginator($query, $fetchJoinCollection): Paginator
{
return new Paginator($query, $fetchJoinCollection);
}
}

View File

@@ -0,0 +1,278 @@
<?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\Doctrine\Orm\Extension;
use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Exception\PropertyNotFoundException;
use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException;
use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Query\Expr\Select;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
/**
* Eager loads relations.
*
* @author Charles Sarrazin <charles@sarraz.in>
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Antoine Bluchet <soyuka@gmail.com>
* @author Baptiste Meyer <baptiste.meyer@gmail.com>
*/
final class EagerLoadingExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly int $maxJoins = 30, private readonly bool $forceEager = true, private readonly bool $fetchPartial = false, private readonly ?ClassMetadataFactoryInterface $classMetadataFactory = null)
{
}
/**
* {@inheritdoc}
*/
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void
{
$this->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
}
/**
* The context may contain serialization groups which helps defining joined entities that are readable.
*/
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
{
$this->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
}
private function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass, ?Operation $operation, array $context): void
{
if (null === $resourceClass) {
throw new InvalidArgumentException('The "$resourceClass" parameter must not be null');
}
$options = [];
$forceEager = $operation?->getForceEager() ?? $this->forceEager;
$fetchPartial = $operation?->getFetchPartial() ?? $this->fetchPartial;
if (!isset($context['groups']) && !isset($context['attributes'])) {
$contextType = isset($context['api_denormalize']) ? 'denormalization_context' : 'normalization_context';
if ($operation) {
$context += 'denormalization_context' === $contextType ? ($operation->getDenormalizationContext() ?? []) : ($operation->getNormalizationContext() ?? []);
}
}
if (empty($context[AbstractNormalizer::GROUPS]) && !isset($context[AbstractNormalizer::ATTRIBUTES])) {
return;
}
if (!empty($context[AbstractNormalizer::GROUPS])) {
$options['serializer_groups'] = (array) $context[AbstractNormalizer::GROUPS];
}
if ($operation && $normalizationGroups = $operation->getNormalizationContext()['groups'] ?? null) {
$options['normalization_groups'] = $normalizationGroups;
}
if ($operation && $denormalizationGroups = $operation->getDenormalizationContext()['groups'] ?? null) {
$options['denormalization_groups'] = $denormalizationGroups;
}
$this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $options, $context);
}
/**
* Joins relations to eager load.
*
* @param bool $wasLeftJoin if the relation containing the new one had a left join, we have to force the new one to left join too
* @param int $joinCount the number of joins
* @param int $currentDepth the current max depth
*
* @throws RuntimeException when the max number of joins has been reached
*/
private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $options = [], array $normalizationContext = [], bool $wasLeftJoin = false, int &$joinCount = 0, ?int $currentDepth = null, ?string $parentAssociation = null): void
{
if ($joinCount > $this->maxJoins) {
throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary with the "api_platform.eager_loading.max_joins" configuration key (https://api-platform.com/docs/core/performance/#eager-loading), or limit the maximum serialization depth using the "enable_max_depth" option of the Symfony serializer (https://symfony.com/doc/current/components/serializer.html#handling-serialization-depth).');
}
$currentDepth = $currentDepth > 0 ? $currentDepth - 1 : $currentDepth;
$entityManager = $queryBuilder->getEntityManager();
$classMetadata = $entityManager->getClassMetadata($resourceClass);
$attributesMetadata = $this->classMetadataFactory?->getMetadataFor($resourceClass)->getAttributesMetadata();
foreach ($classMetadata->associationMappings as $association => $mapping) {
// Don't join if max depth is enabled and the current depth limit is reached
if (0 === $currentDepth && ($normalizationContext[AbstractObjectNormalizer::ENABLE_MAX_DEPTH] ?? false)) {
continue;
}
try {
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $association, $options);
} catch (PropertyNotFoundException) {
// skip properties not found
continue;
// @phpstan-ignore-next-line indeed this can be thrown by the SerializerPropertyMetadataFactory
} catch (ResourceClassNotFoundException) {
// skip associations that are not resource classes
continue;
}
if (
// Always skip extra lazy associations
ClassMetadata::FETCH_EXTRA_LAZY === $mapping['fetch']
// We don't want to interfere with doctrine on this association
|| (false === $forceEager && ClassMetadata::FETCH_EAGER !== $mapping['fetch'])
) {
continue;
}
// prepare the child context
$childNormalizationContext = $normalizationContext;
if (isset($normalizationContext[AbstractNormalizer::ATTRIBUTES])) {
if ($inAttributes = isset($normalizationContext[AbstractNormalizer::ATTRIBUTES][$association])) {
$childNormalizationContext[AbstractNormalizer::ATTRIBUTES] = $normalizationContext[AbstractNormalizer::ATTRIBUTES][$association];
}
} else {
$inAttributes = null;
}
$fetchEager = $propertyMetadata->getFetchEager();
$uriTemplate = $propertyMetadata->getUriTemplate();
if (false === $fetchEager || null !== $uriTemplate) {
continue;
}
if (true !== $fetchEager && (false === $propertyMetadata->isReadable() || false === $inAttributes)) {
continue;
}
// Avoid joining back to the parent that we just came from, but only on *ToOne relations
if (
null !== $parentAssociation
&& isset($mapping['inversedBy'])
&& $mapping['sourceEntity'] === $mapping['targetEntity']
&& $mapping['inversedBy'] === $parentAssociation
&& $mapping['type'] & ClassMetadata::TO_ONE
) {
continue;
}
$existingJoin = QueryBuilderHelper::getExistingJoin($queryBuilder, $parentAlias, $association);
if (null !== $existingJoin) {
$associationAlias = $existingJoin->getAlias();
$isLeftJoin = Join::LEFT_JOIN === $existingJoin->getJoinType();
} else {
$isNullable = $mapping['joinColumns'][0]['nullable'] ?? true;
$isLeftJoin = false !== $wasLeftJoin || true === $isNullable;
$method = $isLeftJoin ? 'leftJoin' : 'innerJoin';
$associationAlias = $queryNameGenerator->generateJoinAlias($association);
$queryBuilder->{$method}(\sprintf('%s.%s', $parentAlias, $association), $associationAlias);
++$joinCount;
}
if (true === $fetchPartial) {
try {
$this->addSelect($queryBuilder, $mapping['targetEntity'], $associationAlias, $options);
} catch (ResourceClassNotFoundException) {
continue;
}
} else {
$this->addSelectOnce($queryBuilder, $associationAlias);
}
// Avoid recursive joins for self-referencing relations
if ($mapping['targetEntity'] === $resourceClass) {
continue;
}
// Only join the relation's relations recursively if it's a readableLink
if (true !== $fetchEager && (true !== $propertyMetadata->isReadableLink())) {
continue;
}
if (isset($attributesMetadata[$association])) {
$maxDepth = $attributesMetadata[$association]->getMaxDepth();
// The current depth is the lowest max depth available in the ancestor tree.
if (null !== $maxDepth && (null === $currentDepth || $maxDepth < $currentDepth)) {
$currentDepth = $maxDepth;
}
}
$this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $options, $childNormalizationContext, $isLeftJoin, $joinCount, $currentDepth, $association);
}
}
private function addSelect(QueryBuilder $queryBuilder, string $entity, string $associationAlias, array $propertyMetadataOptions): void
{
$select = [];
$entityManager = $queryBuilder->getEntityManager();
$targetClassMetadata = $entityManager->getClassMetadata($entity);
if (!empty($targetClassMetadata->subClasses)) {
$this->addSelectOnce($queryBuilder, $associationAlias);
return;
}
foreach ($this->propertyNameCollectionFactory->create($entity) as $property) {
$propertyMetadata = $this->propertyMetadataFactory->create($entity, $property, $propertyMetadataOptions);
if (true === $propertyMetadata->isIdentifier()) {
$select[] = $property;
continue;
}
// If it's an embedded property see below
if (!\array_key_exists($property, $targetClassMetadata->embeddedClasses)) {
$isFetchable = $propertyMetadata->isFetchable();
// the field test allows to add methods to a Resource which do not reflect real database fields
if ($targetClassMetadata->hasField($property) && (true === $isFetchable || $propertyMetadata->isReadable())) {
$select[] = $property;
}
continue;
}
// It's an embedded property, select relevant subfields
foreach ($this->propertyNameCollectionFactory->create($targetClassMetadata->embeddedClasses[$property]['class']) as $embeddedProperty) {
$isFetchable = $propertyMetadata->isFetchable();
$propertyMetadata = $this->propertyMetadataFactory->create($entity, $property, $propertyMetadataOptions);
$propertyName = "$property.$embeddedProperty";
if ($targetClassMetadata->hasField($propertyName) && (true === $isFetchable || $propertyMetadata->isReadable())) {
$select[] = $propertyName;
}
}
}
$queryBuilder->addSelect(\sprintf('partial %s.{%s}', $associationAlias, implode(',', $select)));
}
private function addSelectOnce(QueryBuilder $queryBuilder, string $alias): void
{
$existingSelects = array_reduce($queryBuilder->getDQLPart('select') ?? [], fn ($existing, $dqlSelect) => ($dqlSelect instanceof Select) ? array_merge($existing, $dqlSelect->getParts()) : $existing, []);
if (!\in_array($alias, $existingSelects, true)) {
$queryBuilder->addSelect($alias);
}
}
}

View File

@@ -0,0 +1,201 @@
<?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\Doctrine\Orm\Extension;
use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
/**
* Fixes filters on OneToMany associations
* https://github.com/api-platform/core/issues/944.
*/
final class FilterEagerLoadingExtension implements QueryCollectionExtensionInterface
{
public function __construct(private readonly bool $forceEager = true, private readonly ?ResourceClassResolverInterface $resourceClassResolver = null)
{
}
/**
* {@inheritdoc}
*/
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void
{
if (null === $resourceClass) {
throw new InvalidArgumentException('The "$resourceClass" parameter must not be null');
}
$em = $queryBuilder->getEntityManager();
$classMetadata = $em->getClassMetadata($resourceClass);
$forceEager = $operation?->getForceEager() ?? $this->forceEager;
if (!$forceEager && !$this->hasFetchEagerAssociation($em, $classMetadata)) {
return;
}
// If no where part, nothing to do
$wherePart = $queryBuilder->getDQLPart('where');
if (!$wherePart) {
return;
}
$joinParts = $queryBuilder->getDQLPart('join');
$originAlias = $queryBuilder->getRootAliases()[0];
if (!$joinParts || !isset($joinParts[$originAlias])) {
return;
}
$queryBuilderClone = clone $queryBuilder;
$queryBuilderClone->resetDQLPart('where');
$changedWhereClause = false;
if (!$classMetadata->isIdentifierComposite) {
$replacementAlias = $queryNameGenerator->generateJoinAlias($originAlias);
$in = $this->getQueryBuilderWithNewAliases($queryBuilder, $queryNameGenerator, $originAlias, $replacementAlias);
if ($classMetadata->containsForeignIdentifier) {
$identifier = current($classMetadata->getIdentifier());
$in->select("IDENTITY($replacementAlias.$identifier)");
$queryBuilderClone->andWhere($queryBuilderClone->expr()->in("$originAlias.$identifier", $in->getDQL()));
} else {
$in->select($replacementAlias);
$queryBuilderClone->andWhere($queryBuilderClone->expr()->in($originAlias, $in->getDQL()));
}
$changedWhereClause = true;
} else {
// Because Doctrine doesn't support WHERE ( foo, bar ) IN () (https://github.com/doctrine/doctrine2/issues/5238), we are building as many subqueries as they are identifiers
foreach ($classMetadata->getIdentifier() as $identifier) {
if (!$classMetadata->hasAssociation($identifier)) {
continue;
}
$replacementAlias = $queryNameGenerator->generateJoinAlias($originAlias);
$in = $this->getQueryBuilderWithNewAliases($queryBuilder, $queryNameGenerator, $originAlias, $replacementAlias);
$in->select("IDENTITY($replacementAlias.$identifier)");
$queryBuilderClone->andWhere($queryBuilderClone->expr()->in("$originAlias.$identifier", $in->getDQL()));
$changedWhereClause = true;
}
}
if (false === $changedWhereClause) {
return;
}
$queryBuilder->resetDQLPart('where');
$queryBuilder->add('where', $queryBuilderClone->getDQLPart('where'));
}
/**
* Checks if the class has an associationMapping with FETCH=EAGER.
*
* @param array $checked array cache of tested metadata classes
*/
private function hasFetchEagerAssociation(EntityManagerInterface $em, ClassMetadata $classMetadata, array &$checked = []): bool
{
$checked[] = $classMetadata->name;
foreach ($classMetadata->getAssociationMappings() as $mapping) {
if (ClassMetadata::FETCH_EAGER === $mapping['fetch']) {
return true;
}
$related = $em->getClassMetadata($mapping['targetEntity']);
if (\in_array($related->name, $checked, true)) {
continue;
}
if (true === $this->hasFetchEagerAssociation($em, $related, $checked)) {
return true;
}
}
return false;
}
/**
* Returns a clone of the given query builder where everything gets re-aliased.
*
* @param string $originAlias the base alias
* @param string $replacement the replacement for the base alias, will change the from alias
*/
private function getQueryBuilderWithNewAliases(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $originAlias = 'o', string $replacement = 'o_2'): QueryBuilder
{
$queryBuilderClone = clone $queryBuilder;
$joinParts = $queryBuilder->getDQLPart('join');
$wherePart = $queryBuilder->getDQLPart('where');
// reset parts
$queryBuilderClone->resetDQLPart('join');
$queryBuilderClone->resetDQLPart('where');
$queryBuilderClone->resetDQLPart('orderBy');
$queryBuilderClone->resetDQLPart('groupBy');
$queryBuilderClone->resetDQLPart('having');
// Change from alias
$from = $queryBuilderClone->getDQLPart('from')[0];
$queryBuilderClone->resetDQLPart('from');
$queryBuilderClone->from($from->getFrom(), $replacement);
$aliases = ["$originAlias."];
$replacements = ["$replacement."];
// Change join aliases
foreach ($joinParts[$originAlias] as $joinPart) {
/** @var Join $joinPart */
$joinString = preg_replace($this->buildReplacePatterns($aliases), $replacements, $joinPart->getJoin());
$pos = strpos($joinString, '.');
$joinCondition = (string) $joinPart->getCondition();
if (false === $pos) {
if ($joinCondition && $this->resourceClassResolver?->isResourceClass($joinString)) {
$newAlias = $queryNameGenerator->generateJoinAlias($joinPart->getAlias());
$aliases[] = "{$joinPart->getAlias()}.";
$replacements[] = "$newAlias.";
$condition = preg_replace($this->buildReplacePatterns($aliases), $replacements, $joinCondition);
$join = new Join($joinPart->getJoinType(), $joinPart->getJoin(), $newAlias, $joinPart->getConditionType(), $condition);
$queryBuilderClone->add('join', [$replacement => $join], true); // @phpstan-ignore-line
}
continue;
}
$alias = substr($joinString, 0, $pos);
$association = substr($joinString, $pos + 1);
$newAlias = $queryNameGenerator->generateJoinAlias($association);
$aliases[] = "{$joinPart->getAlias()}.";
$replacements[] = "$newAlias.";
$condition = preg_replace($this->buildReplacePatterns($aliases), $replacements, $joinCondition);
QueryBuilderHelper::addJoinOnce($queryBuilderClone, $queryNameGenerator, $alias, $association, $joinPart->getJoinType(), $joinPart->getConditionType(), $condition, $originAlias, $newAlias);
}
$queryBuilderClone->add('where', preg_replace($this->buildReplacePatterns($aliases), $replacements, (string) $wherePart));
return $queryBuilderClone;
}
private function buildReplacePatterns(array $aliases): array
{
return array_map(static fn (string $alias): string => '/\b'.preg_quote($alias, '/').'/', $aliases);
}
}

View File

@@ -0,0 +1,72 @@
<?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\Doctrine\Orm\Extension;
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Psr\Container\ContainerInterface;
/**
* Applies filters on a resource query.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Samuel ROZE <samuel.roze@gmail.com>
*/
final class FilterExtension implements QueryCollectionExtensionInterface
{
public function __construct(private readonly ContainerInterface $filterLocator)
{
}
/**
* {@inheritdoc}
*/
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void
{
if (null === $resourceClass) {
throw new InvalidArgumentException('The "$resourceClass" parameter must not be null');
}
$resourceFilters = $operation?->getFilters();
if (empty($resourceFilters)) {
return;
}
$orderFilters = [];
foreach ($resourceFilters as $filterId) {
$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
if ($filter instanceof FilterInterface) {
// Apply the OrderFilter after every other filter to avoid an edge case where OrderFilter would do a LEFT JOIN instead of an INNER JOIN
if ($filter instanceof OrderFilter) {
$orderFilters[] = $filter;
continue;
}
$context['filters'] ??= [];
$filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
}
}
foreach ($orderFilters as $orderFilter) {
$context['filters'] ??= [];
$orderFilter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
}
}
}

View File

@@ -0,0 +1,89 @@
<?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\Doctrine\Orm\Extension;
use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
/**
* Applies selected ordering while querying resource collection.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Samuel ROZE <samuel.roze@gmail.com>
* @author Vincent Chalamon <vincentchalamon@gmail.com>
*/
final class OrderExtension implements QueryCollectionExtensionInterface
{
public function __construct(private readonly ?string $order = null)
{
}
/**
* {@inheritdoc}
*/
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): void
{
if (null === $resourceClass) {
throw new InvalidArgumentException('The "$resourceClass" parameter must not be null');
}
// Do not apply order if already defined on queryBuilder
$orderByDqlPart = $queryBuilder->getDQLPart('orderBy');
if (\is_array($orderByDqlPart) && \count($orderByDqlPart) > 0) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$classMetaData = $queryBuilder->getEntityManager()->getClassMetadata($resourceClass);
$identifiers = $classMetaData->getIdentifier();
$defaultOrder = $operation?->getOrder() ?? [];
if ([] !== $defaultOrder) {
foreach ($defaultOrder as $field => $order) {
if (\is_int($field)) {
// Default direction
$field = $order;
$order = 'ASC';
}
$pos = strpos($field, '.');
if (false === $pos || isset($classMetaData->embeddedClasses[substr($field, 0, $pos)])) {
// Configure default filter with property
$field = "{$rootAlias}.{$field}";
} else {
$alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $rootAlias, substr($field, 0, $pos));
$field = \sprintf('%s.%s', $alias, substr($field, $pos + 1));
}
$queryBuilder->addOrderBy($field, $order);
}
return;
}
if (null !== $this->order) {
// A foreign identifier cannot be used for ordering.
if ($classMetaData->containsForeignIdentifier) {
return;
}
foreach ($identifiers as $identifier) {
$queryBuilder->addOrderBy("{$rootAlias}.{$identifier}", $this->order);
}
}
}
}

View File

@@ -0,0 +1,212 @@
<?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\Doctrine\Orm\Extension;
use ApiPlatform\Doctrine\Orm\AbstractPaginator;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Doctrine\Orm\Util\QueryChecker;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\CountWalker;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrineOrmPaginator;
use Doctrine\Persistence\ManagerRegistry;
// Help opcache.preload discover always-needed symbols
class_exists(AbstractPaginator::class);
/**
* Applies pagination on the Doctrine query for resource collection when enabled.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Samuel ROZE <samuel.roze@gmail.com>
*/
final class PaginationExtension implements QueryResultCollectionExtensionInterface
{
public function __construct(private readonly ManagerRegistry $managerRegistry, private readonly ?Pagination $pagination)
{
}
/**
* {@inheritdoc}
*/
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
if (null === $pagination = $this->getPagination($queryBuilder, $operation, $context)) {
return;
}
[$offset, $limit] = $pagination;
$queryBuilder
->setFirstResult($offset)
->setMaxResults($limit);
}
/**
* {@inheritdoc}
*/
public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool
{
if ($context['graphql_operation_name'] ?? false) {
return $this->pagination->isGraphQlEnabled($operation, $context);
}
return $this->pagination->isEnabled($operation, $context);
}
/**
* {@inheritdoc}
*/
public function getResult(QueryBuilder $queryBuilder, ?string $resourceClass = null, ?Operation $operation = null, array $context = []): iterable
{
$query = $queryBuilder->getQuery();
// Only one alias, without joins, disable the DISTINCT on the COUNT
if (1 === \count($queryBuilder->getAllAliases())) {
$query->setHint(CountWalker::HINT_DISTINCT, false);
}
$doctrineOrmPaginator = new DoctrineOrmPaginator($query, $this->shouldDoctrinePaginatorFetchJoinCollection($queryBuilder, $operation, $context));
$doctrineOrmPaginator->setUseOutputWalkers($this->shouldDoctrinePaginatorUseOutputWalkers($queryBuilder, $operation, $context));
$isPartialEnabled = $this->pagination->isPartialEnabled($operation, $context);
if ($isPartialEnabled) {
return new class($doctrineOrmPaginator) extends AbstractPaginator {
};
}
return new Paginator($doctrineOrmPaginator);
}
/**
* @throws InvalidArgumentException
*/
private function getPagination(QueryBuilder $queryBuilder, ?Operation $operation, array $context): ?array
{
$enabled = isset($context['graphql_operation_name']) ? $this->pagination->isGraphQlEnabled($operation, $context) : $this->pagination->isEnabled($operation, $context);
if (!$enabled) {
return null;
}
$context = $this->addCountToContext($queryBuilder, $context);
return \array_slice($this->pagination->getPagination($operation, $context), 1);
}
private function addCountToContext(QueryBuilder $queryBuilder, array $context): array
{
if (!($context['graphql_operation_name'] ?? false)) {
return $context;
}
if (isset($context['filters']['last']) && !isset($context['filters']['before'])) {
$context['count'] = (new DoctrineOrmPaginator($queryBuilder))->count();
}
return $context;
}
/**
* Determines the value of the $fetchJoinCollection argument passed to the Doctrine ORM Paginator.
*/
private function shouldDoctrinePaginatorFetchJoinCollection(QueryBuilder $queryBuilder, ?Operation $operation = null, array $context = []): bool
{
$fetchJoinCollection = $operation?->getPaginationFetchJoinCollection();
if (isset($context['operation_name']) && isset($fetchJoinCollection)) {
return $fetchJoinCollection;
}
if (isset($context['graphql_operation_name']) && isset($fetchJoinCollection)) {
return $fetchJoinCollection;
}
/*
* "Cannot count query which selects two FROM components, cannot make distinction"
*
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/WhereInWalker.php#L81
* @see https://github.com/doctrine/doctrine2/issues/2910
*/
if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
return false;
}
if (QueryChecker::hasJoinedToManyAssociation($queryBuilder, $this->managerRegistry)) {
return true;
}
// disable $fetchJoinCollection by default (performance)
return false;
}
/**
* Determines whether the Doctrine ORM Paginator should use output walkers.
*/
private function shouldDoctrinePaginatorUseOutputWalkers(QueryBuilder $queryBuilder, ?Operation $operation = null, array $context = []): bool
{
$useOutputWalkers = $operation?->getPaginationUseOutputWalkers();
if (isset($context['operation_name']) && isset($useOutputWalkers)) {
return $useOutputWalkers;
}
if (isset($context['graphql_operation_name']) && isset($useOutputWalkers)) {
return $useOutputWalkers;
}
/*
* "Cannot count query that uses a HAVING clause. Use the output walkers for pagination"
*
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L56
*/
if (QueryChecker::hasHavingClause($queryBuilder)) {
return true;
}
/*
* "Cannot count query which selects two FROM components, cannot make distinction"
*
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L64
*/
if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
return true;
}
/*
* "Paginating an entity with foreign key as identifier only works when using the Output Walkers. Call Paginator#setUseOutputWalkers(true) before iterating the paginator."
*
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L77
*/
if (QueryChecker::hasRootEntityWithForeignKeyIdentifier($queryBuilder, $this->managerRegistry)) {
return true;
}
/*
* "Cannot select distinct identifiers from query with LIMIT and ORDER BY on a column from a fetch joined to-many association. Use output walkers."
*
* @see https://github.com/doctrine/orm/blob/v2.6.3/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L150
*/
if (QueryChecker::hasMaxResults($queryBuilder) && QueryChecker::hasOrderByOnFetchJoinedToManyAssociation($queryBuilder, $this->managerRegistry)) {
return true;
}
// Disable output walkers by default (performance)
return false;
}
}

View File

@@ -0,0 +1,74 @@
<?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\Doctrine\Orm\Extension;
use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait;
use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ParameterNotFound;
use Doctrine\ORM\QueryBuilder;
use Psr\Container\ContainerInterface;
/**
* Reads operation parameters and execute its filter.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
use ParameterValueExtractorTrait;
public function __construct(private readonly ContainerInterface $filterLocator)
{
}
/**
* @param array<string, mixed> $context
*/
private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
foreach ($operation?->getParameters() ?? [] as $parameter) {
if (null === ($v = $parameter->getValue()) || $v instanceof ParameterNotFound) {
continue;
}
$values = $this->extractParameterValue($parameter, $v);
if (null === ($filterId = $parameter->getFilter())) {
continue;
}
$filter = $this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null;
if ($filter instanceof FilterInterface) {
$filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, ['filters' => $values, 'parameter' => $parameter] + $context);
}
}
}
/**
* {@inheritdoc}
*/
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
}
/**
* {@inheritdoc}
*/
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
{
$this->applyFilter($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
}
}

Some files were not shown because too many files have changed in this diff Show More