SearchFilter: MissingIdentifierField exception if API identifier not equals Doctrine identifier
API Platform version(s) affected: 2.6.6
Description
There are products that have a relation to a unit. The units have a primary key "id" and a property "symbol" (g, kg, l, ml). For the API, "symbol" is used as identifier instead of "id".
I added a SearchFilter to the product resource for the unit. When I filter the resource collection using the IRI of a unit, a "Doctrine\ORM\Exception\MissingIdentifierField" exception with the message "The identifier id is missing for a query of App\Entity\ContentUnit" is thrown. Filtering only using the numeric "id" still works.
Does not work: /api/products?page=1&itemsPerPage=30&contentUnit=%2Fapi%2Fcontent-units%2Fkg
How to reproduce
src/Entity/ContentUnit.php:
/**
* @ApiResource()
*
* @ORM\Entity(repositoryClass=ContentUnitRepository::class)
*/
class ContentUnit
{
/**
* @ApiProperty(identifier=false)
*
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ApiProperty(identifier=true)
*
* @ORM\Column(type="string", length=180)
*/
private $symbol;
// ...
}
src/Entity/Product.php:
/**
* @ApiResource()
* @ApiFilter(SearchFilter::class, properties={"contentUnit"})
*
* @ORM\Entity(repositoryClass=ProductRepository::class)
*/
class Product
{
// ...
/**
* @ORM\ManyToOne(targetEntity=ContentUnit::class, inversedBy="products")
* @ORM\JoinColumn(nullable=false)
*/
private $contentUnit;
// ...
}
Possible Solution
The SearchFilter calls IriConverter::getItemFromIri() with the context ['fetch_data' => false]. This causes Doctrine\ORM\EntityManagerInterface::getReference() to be called and at this point the Doctrine identifiers are needed. I think at this point a detection needs to be added to check if the Doctrine identifiers match the API identifiers. If not, the entity must be loaded using the API identifier.
For the moment, I "fixed" this by decorating api_platform.doctrine.orm.default.item_data_provider with my own ItemDataProvider:
<?php
namespace App\Api\Doctrine\DataProvider;
use ApiPlatform\Core\DataProvider\DenormalizedIdentifiersAwareItemDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use Doctrine\Persistence\ManagerRegistry;
use function array_keys;
use function in_array;
use function is_array;
class ItemDataProvider implements DenormalizedIdentifiersAwareItemDataProviderInterface, RestrictedDataProviderInterface
{
/**
* @var DenormalizedIdentifiersAwareItemDataProviderInterface
*/
private $decorated;
/**
* @var ManagerRegistry
*/
private $managerRegistry;
public function __construct(
DenormalizedIdentifiersAwareItemDataProviderInterface $decorated,
ManagerRegistry $managerRegistry
) {
$this->decorated = $decorated;
$this->managerRegistry = $managerRegistry;
}
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = []): ?object
{
if (!($context['fetch_data'] ?? true)) {
$context['fetch_data'] = $this->enableFetchData($resourceClass, $id, $context);
}
return $this->decorated->getItem($resourceClass, $id, $operationName, $context);
}
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
if ($this->decorated instanceof RestrictedDataProviderInterface) {
return $this->decorated->supports($resourceClass, $operationName, $context);
}
return false;
}
private function enableFetchData(string $resourceClass, $id, array $context): bool
{
if (!($context['has_identifier_converter'] ?? false) || !is_array($id)) {
return false;
}
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
$doctrineClassMetadata = $manager->getClassMetadata($resourceClass);
$doctrineIdentifierFields = $doctrineClassMetadata->getIdentifier();
$apiIdentifierFields = array_keys($id);
foreach ($doctrineIdentifierFields as $doctrineIdentifierField) {
if (!in_array($doctrineIdentifierField, $apiIdentifierFields)) {
return true;
}
}
return false;
}
}
Additional Context
Stacktrace:
Doctrine\ORM\Exception\MissingIdentifierField:
The identifier id is missing for a query of App\Entity\ContentUnit
at vendor/doctrine/orm/lib/Doctrine/ORM/Exception/MissingIdentifierField.php:15
at Doctrine\ORM\Exception\MissingIdentifierField::fromFieldAndClass('id', 'App\\Entity\\ContentUnit')
(vendor/doctrine/orm/lib/Doctrine/ORM/EntityManager.php:516)
at Doctrine\ORM\EntityManager->getReference('App\\Entity\\ContentUnit', array('symbol' => 'kg'))
(var/cache/dev/Container8hu3rc7/EntityManager_9a5be93.php:151)
at Container8hu3rc7\EntityManager_9a5be93->getReference('App\\Entity\\ContentUnit', array('symbol' => 'kg'))
(vendor/api-platform/core/src/Bridge/Doctrine/Orm/ItemDataProvider.php:83)
at ApiPlatform\Core\Bridge\Doctrine\Orm\ItemDataProvider->getItem('App\\Entity\\ContentUnit', array('symbol' => 'kg'), 'get', array('fetch_data' => false, 'has_identifier_converter' => true))
(vendor/api-platform/core/src/Bridge/Symfony/Bundle/DataProvider/TraceableChainItemDataProvider.php:75)
at ApiPlatform\Core\Bridge\Symfony\Bundle\DataProvider\TraceableChainItemDataProvider->getItem('App\\Entity\\ContentUnit', array('symbol' => 'kg'), 'get', array('fetch_data' => false, 'has_identifier_converter' => true))
(vendor/api-platform/core/src/DataProvider/OperationDataProviderTrait.php:64)
at ApiPlatform\Core\Bridge\Symfony\Routing\IriConverter->getItemData(array('symbol' => 'kg'), array('resource_class' => 'App\\Entity\\ContentUnit', 'has_composite_identifier' => false, 'identifiers' => array('symbol' => array('App\\Entity\\ContentUnit', 'symbol')), 'item_operation_name' => 'get', 'receive' => true, 'respond' => true, 'persist' => true), array('fetch_data' => false, 'has_identifier_converter' => true))
(vendor/api-platform/core/src/Bridge/Symfony/Routing/IriConverter.php:106)
at ApiPlatform\Core\Bridge\Symfony\Routing\IriConverter->getItemFromIri('/api/content-units/kg', array('fetch_data' => false, 'has_identifier_converter' => true))
(vendor/api-platform/core/src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php:123)
at ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter->getIdFromValue('/api/content-units/kg')
at array_map(array(object(SearchFilter), 'getIdFromValue'), array('/api/content-units/kg'))
(vendor/api-platform/core/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php:126)
at ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter->filterProperty('contentUnit', '/api/content-units/kg', object(QueryBuilder), object(QueryNameGenerator), 'App\\Entity\\Product', 'get', array('filters' => array('page' => '1', 'itemsPerPage' => '30', 'contentUnit' => '/api/content-units/kg'), 'groups' => array('product:read'), 'operation_type' => 'collection', 'collection_operation_name' => 'get', 'resource_class' => 'App\\Entity\\Product', 'iri_only' => false, 'input' => null, 'output' => array('class' => 'App\\Api\\Dto\\ProductOutput', 'name' => 'ProductOutput'), 'request_uri' => '/api/products?page=1&itemsPerPage=30&contentUnit=%2Fapi%2Fcontent-units%2Fkg', 'uri' => 'http://localhost:3000/api/products?contentUnit=%2Fapi%2Fcontent-units%2Fkg&itemsPerPage=30&page=1', 'app_api_store' => null))
(vendor/api-platform/core/src/Bridge/Doctrine/Orm/Filter/AbstractContextAwareFilter.php:33)
at ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter->apply(object(QueryBuilder), object(QueryNameGenerator), 'App\\Entity\\Product', 'get', array('filters' => array('page' => '1', 'itemsPerPage' => '30', 'contentUnit' => '/api/content-units/kg'), 'groups' => array('product:read'), 'operation_type' => 'collection', 'collection_operation_name' => 'get', 'resource_class' => 'App\\Entity\\Product', 'iri_only' => false, 'input' => null, 'output' => array('class' => 'App\\Api\\Dto\\ProductOutput', 'name' => 'ProductOutput'), 'request_uri' => '/api/products?page=1&itemsPerPage=30&contentUnit=%2Fapi%2Fcontent-units%2Fkg', 'uri' => 'http://localhost:3000/api/products?contentUnit=%2Fapi%2Fcontent-units%2Fkg&itemsPerPage=30&page=1', 'app_api_store' => null))
(vendor/api-platform/core/src/Bridge/Doctrine/Orm/Extension/FilterExtension.php:76)
at ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\FilterExtension->applyToCollection(object(QueryBuilder), object(QueryNameGenerator), 'App\\Entity\\Product', 'get', array('filters' => array('page' => '1', 'itemsPerPage' => '30', 'contentUnit' => '/api/content-units/kg'), 'groups' => array('product:read'), 'operation_type' => 'collection', 'collection_operation_name' => 'get', 'resource_class' => 'App\\Entity\\Product', 'iri_only' => false, 'input' => null, 'output' => array('class' => 'App\\Api\\Dto\\ProductOutput', 'name' => 'ProductOutput'), 'request_uri' => '/api/products?page=1&itemsPerPage=30&contentUnit=%2Fapi%2Fcontent-units%2Fkg', 'uri' => 'http://localhost:3000/api/products?contentUnit=%2Fapi%2Fcontent-units%2Fkg&itemsPerPage=30&page=1', 'app_api_store' => null))
(vendor/api-platform/core/src/Bridge/Doctrine/Orm/CollectionDataProvider.php:69)
at ApiPlatform\Core\Bridge\Doctrine\Orm\CollectionDataProvider->getCollection('App\\Entity\\Product', 'get', array('filters' => array('page' => '1', 'itemsPerPage' => '30', 'contentUnit' => '/api/content-units/kg'), 'groups' => array('product:read'), 'operation_type' => 'collection', 'collection_operation_name' => 'get', 'resource_class' => 'App\\Entity\\Product', 'iri_only' => false, 'input' => null, 'output' => array('class' => 'App\\Api\\Dto\\ProductOutput', 'name' => 'ProductOutput'), 'request_uri' => '/api/products?page=1&itemsPerPage=30&contentUnit=%2Fapi%2Fcontent-units%2Fkg', 'uri' => 'http://localhost:3000/api/products?contentUnit=%2Fapi%2Fcontent-units%2Fkg&itemsPerPage=30&page=1', 'app_api_store' => null))
(vendor/api-platform/core/src/Bridge/Symfony/Bundle/DataProvider/TraceableChainCollectionDataProvider.php:65)
at ApiPlatform\Core\Bridge\Symfony\Bundle\DataProvider\TraceableChainCollectionDataProvider->getCollection('App\\Entity\\Product', 'get', array('filters' => array('page' => '1', 'itemsPerPage' => '30', 'contentUnit' => '/api/content-units/kg'), 'groups' => array('product:read'), 'operation_type' => 'collection', 'collection_operation_name' => 'get', 'resource_class' => 'App\\Entity\\Product', 'iri_only' => false, 'input' => null, 'output' => array('class' => 'App\\Api\\Dto\\ProductOutput', 'name' => 'ProductOutput'), 'request_uri' => '/api/products?page=1&itemsPerPage=30&contentUnit=%2Fapi%2Fcontent-units%2Fkg', 'uri' => 'http://localhost:3000/api/products?contentUnit=%2Fapi%2Fcontent-units%2Fkg&itemsPerPage=30&page=1', 'app_api_store' => null))
(vendor/api-platform/core/src/DataProvider/OperationDataProviderTrait.php:54)
at ApiPlatform\Core\EventListener\ReadListener->getCollectionData(array('resource_class' => 'App\\Entity\\Product', 'has_composite_identifier' => false, 'identifiers' => array('id' => array('App\\Entity\\Product', 'id')), 'collection_operation_name' => 'get', 'receive' => true, 'respond' => true, 'persist' => true), array('filters' => array('page' => '1', 'itemsPerPage' => '30', 'contentUnit' => '/api/content-units/kg'), 'groups' => array('product:read'), 'operation_type' => 'collection', 'collection_operation_name' => 'get', 'resource_class' => 'App\\Entity\\Product', 'iri_only' => false, 'input' => null, 'output' => array('class' => 'App\\Api\\Dto\\ProductOutput', 'name' => 'ProductOutput'), 'request_uri' => '/api/products?page=1&itemsPerPage=30&contentUnit=%2Fapi%2Fcontent-units%2Fkg', 'uri' => 'http://localhost:3000/api/products?contentUnit=%2Fapi%2Fcontent-units%2Fkg&itemsPerPage=30&page=1', 'app_api_store' => null))
(vendor/api-platform/core/src/EventListener/ReadListener.php:87)
at ApiPlatform\Core\EventListener\ReadListener->onKernelRequest(object(RequestEvent), 'kernel.request', object(TraceableEventDispatcher))
(vendor/symfony/event-dispatcher/Debug/WrappedListener.php:117)
at Symfony\Component\EventDispatcher\Debug\WrappedListener->__invoke(object(RequestEvent), 'kernel.request', object(TraceableEventDispatcher))
(vendor/symfony/event-dispatcher/EventDispatcher.php:230)
at Symfony\Component\EventDispatcher\EventDispatcher->callListeners(array(object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener), object(WrappedListener)), 'kernel.request', object(RequestEvent))
(vendor/symfony/event-dispatcher/EventDispatcher.php:59)
at Symfony\Component\EventDispatcher\EventDispatcher->dispatch(object(RequestEvent), 'kernel.request')
(vendor/symfony/event-dispatcher/Debug/TraceableEventDispatcher.php:154)
at Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher->dispatch(object(RequestEvent), 'kernel.request')
(vendor/symfony/http-kernel/HttpKernel.php:128)
at Symfony\Component\HttpKernel\HttpKernel->handleRaw(object(Request), 1)
(vendor/symfony/http-kernel/HttpKernel.php:74)
at Symfony\Component\HttpKernel\HttpKernel->handle(object(Request), 1, true)
(vendor/symfony/http-kernel/Kernel.php:202)
at Symfony\Component\HttpKernel\Kernel->handle(object(Request))
(vendor/symfony/runtime/Runner/Symfony/HttpKernelRunner.php:35)
at Symfony\Component\Runtime\Runner\Symfony\HttpKernelRunner->run()
(vendor/autoload_runtime.php:35)
at require_once('/srv/app/vendor/autoload_runtime.php')
(public/index.php:5)
This issue does still exist in 2.6.8. The decoration works, thanks. Might be a good pr @soyuka what do you think?
@ihmels your ItemDataProvider works fine for ManyToOne relations but not for ManyToMany relations. Any idea for these?
Use a custom provider, the searchfilter will need refactoring and we'll work on it.