core icon indicating copy to clipboard operation
core copied to clipboard

SearchFilter: MissingIdentifierField exception if API identifier not equals Doctrine identifier

Open ihmels opened this issue 4 years ago • 2 comments

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)      

ihmels avatar Dec 23 '21 10:12 ihmels

This issue does still exist in 2.6.8. The decoration works, thanks. Might be a good pr @soyuka what do you think?

Cruiser13 avatar Apr 12 '22 15:04 Cruiser13

@ihmels your ItemDataProvider works fine for ManyToOne relations but not for ManyToMany relations. Any idea for these?

Cruiser13 avatar May 13 '22 09:05 Cruiser13

Use a custom provider, the searchfilter will need refactoring and we'll work on it.

soyuka avatar Sep 16 '22 10:09 soyuka