core
core copied to clipboard
SearchFilter exact subresource on another identifier with IRI fails
API Platform version(s) affected: 2.5.5
Description
SearchFilter subresource with changed identifiers works with identifier but not with IRI identifier
How to reproduce
Add 2 classe like in https://api-platform.com/docs/core/identifiers/#changing-identifier-in-a-doctrine-entity , Person and Process
in class Person add
/**
* @ORM\ManyToOne(targetEntity="App\Entity\Process", inversedBy="persons")
*/
private $process;
NOT working
http://localhost/api/person?process=%2Fapi%2Fprocesses%2F65b810c4-9db0-11ea-a2bd-a683e78c6b85
with error:
The identifier id is missing for a query of App\\Entity\\Process
Possible Solution
The first request hits vendor/doctrine/orm/lib/Doctrine/ORM/EntityManager.php line 487 where the entity has no code set as identifier, only the id
Update 1:
Ohh my gosh, the SQL is formed around the id of the relation, that's why filtering by UUID only will give an empty array. Must be a filter of the field of subresource `process.uuid'. There is any way to use the IRI for it?
I am encountering the same issue when filtering on entities that have both numeric ID + uuid fields.
Reproduction
Here's a sandbox project reproducing the bug via the api-platform template with two entities: https://github.com/alexdo/api-platform-uuid-relation Fixtures included; so make sure you run those when trying to reproduce ;)
In the example, a Greeting has many Greeters and Greeters have many Greetings.
The uuid-Fields of both entities are marked as @ApiProperty(identifier=true).
There's an @ApiFilter(SearchFilter::class, properties={"greeters": "exact"}) annotation on the Greeting class.
I'd expect GET https://localhost:8443/greetings?greeters=[some greeter IRI] to return a collection of greetings present in the many-to-many relation but instead see the same exception as described above: Pastebin link to the full stacktrace
Interestingly, GET https://localhost:8443/greetings?greeters=[some greeter UUID] is working fine. So @ApiProperty(identifier=true) resolution appears to work correctly but IRI resolution + custom identifier doesn't (pure speculation tbh).
Tracking it down
It appears that it all begins in https://github.com/api-platform/core/blob/master/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php#L143 That's calling getIdFromValue() on SearchFilterTrait https://github.com/api-platform/core/blob/master/src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php#L120-L130 which, in turn, tries to resolve the IRI => ID by hitting the IriConverter->getItemFromIri($value, ['fetch_data' => false]) But, as fetch_data is set to false, the ItemDataProvider will try to create a Reference: https://github.com/api-platform/core/blob/master/src/Bridge/Doctrine/Orm/ItemDataProvider.php#L81-L84 Call with arguments written out:
$manager->getReference(Greeter::class, ["uuid" => (uuid of my greeter)]);
However, the Doctrine ORM will, of course, only look at the @ORM\Id property - and not at the API one ;)
This isn't a problem when using UUIDs directly, as IriConverter->getItemFromIri just bails out when looking for a matching Route ;)
Fixing this - where to begin?
I'd really like to provide a PR for this but don't really know where to start.
An option could be to prevent resolving the Itemdata in this problematic case (when doctrine id field !== api platform id), just resolve the IRI route attributes and adjust the final query to use uuid instead of the primary key. In other words:
- Try to catch or prevent
Doctrine\ORM\ORMException: The identifier id is missing for a query of Xsomehow - Use the raw route "id" attribute instead
- => Result: Same behaviour as when just using the UUID (instead of the IRI)
The best way of implementing this could possibly be splitting IriConverter->getItemFromIri into two methods: One that does raw route lookups and another one that uses these lookups + creates the reference.
WDYT?
I'm facing the same issue at the moment. Watching this issue and willing to give some help ! But it seems to have a huge impact in the core of Api Platform and could potentially introduce huge BC breaks, even if those will apply only when using @ApiProperty.
My current situation: I must have 2 unique identifiers:
- one private autoincremented integer generated by the SGBD => id
- one UUID to be exposed in every payload/endpoints => publicId
Because I only share the UUID, I added the annotation @ApiProperty on $publicId.
Doing this, all my tests using /entity?relatedEntity=/relatedEntity/<<UUID>> or /entity?relatedEntity=<<UUID>> do fails because the value is mapped to entity.related_entity_id = <<UUID>>.
The ideal situation will be to add a WHERE clause using related_entity.publidId = <<UUID>>.
I'm also facing the same problem, and watching this issue.
I have an Entity with an associated many-to-one relationship to EntityType.
On EntityType I have used the annotation @ApiProperty(identifier=false) on the database ID, and placed annotation @ApiProperty(identifier=true) on a $code property instead.
The result is that EntityType has an IRI like /api/entity_type/ABC instead of /api/entity_type/1 (just an example).
This works correctly for GET requests (eg. GET /api/entity_type/ABC works fine), and also works as expected when performing a POST on Entity, for example using body:
{
"type": "/api/entity_type/ABC",
/* other properties here */
}
However, it does not work correctly when using SearchFilter. My annotation is @ApiFilter(SearchFilter::class, properties={"entityType": "exact"}) and I expect to be able to query like this: GET /api/entity?entityType=ABC but this fails, instead I am forced to use the database ID as so: GET /api/entity?entityType=1.
As a workaround, I have changed my SearchFilter annotation to: @ApiFilter(SearchFilter::class, properties={"entityType.code": "exact"}) so now my query looks like this: GET /api/entity?entityType.code=ABC
I tried looking at the code but could not figure out a solution, sorry. Am hoping this gets some attention. Thanks.
Same here.
For now i'm forcing to use in entityA:
@ApiFilter(SearchFilter::class, properties={"entityB.uuid": "exact"})
Based on SearchFilter documentation:
It is possible to filter on relations too, if Offer has a Product relation:
<?php
// api/src/Entity/Offer.php
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
#[ApiResource]
#[ApiFilter(SearchFilter::class, properties: ['product' => 'exact'])]
class Offer
{
// ...
}
With this service definition, it is possible to find all offers belonging to the product identified by a given IRI. Try the following: http://localhost:8000/api/offers?product=/api/products/12. Using a numeric ID is also supported: http://localhost:8000/api/offers?product=12
The above URLs will return all offers for the product having the following IRI as JSON-LD identifier (@id): http://localhost:8000/api/products/12
I also modified the entity identifier, and I can't filter searches by custom IRI id. Even though api-platform correctly identifies the custom relationship of the identifier (like mentioned on documentation) in the entity in any situation using @ApiProperty(identifier=false/true) the problem discussed here occurs.
I too am experiencing this same issue, and get "The identifier id is missing for a query of App\\Entity\\Project\\ProjectStage" when filtering on Project using https://example.com/projects?page=1&projectStage=%2Fproject_stages%2Fconstruction . The search filter references an inherited entity, however, it appears that it has nothing to do with inheritance but only using an identifier other than the Doctrine PK id. I tried the possible fixes described by this issue (i.e. search on a sub-property) but when doing so, all records are returned without any filtering. One thought is to rename AbstractRankedList's Doctrine PK from $id to $identifier and API-Platform's identifier from $identifier to $id, but seems like a hack. Anyone have any ideas? Thanks
#[ORM\Entity]
#[ApiResource]
#[ApiFilter(SearchFilter::class, properties: ['projectStage' => 'exact'])]
//#[ApiFilter(SearchFilter::class, properties: ['projectStage.identifier' => 'exact'])]
class Project
{
#[ORM\ManyToOne(targetEntity: ProjectStage::class)]
private ?ProjectStage $projectStage = null;
// Other properties and typical getters and setters
}
#[ORM\Entity]
#[ORM\InheritanceType(value: 'JOINED')]
#[ORM\DiscriminatorColumn(name: 'discriminator', type: 'string')]
#[ORM\UniqueConstraint(columns: ['identifier', 'discriminator'])]
abstract class AbstractRankedList extends AbstractEntity implements RankedListInterface
{
#[ApiProperty(identifier: false)]
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Ignore]
protected ?int $id = null;
#[ApiProperty(identifier: true)]
#[SerializedName("id")]
#[ORM\Column(type: 'string', length: 255)]
protected ?string $identifier = null;
#[ORM\Column(type: 'string', length: 180)]
protected ?string $name = null;
// Other properties and typical getters and setters
}
#[ORM\Entity]
#[ApiResource]
class ProjectStage extends AbstractRankedList
{
}
Is there any good workaround? I've tried @naxo8628 and @pauljura approach but it doesn't work.
@alexdo how did you solve this issue?
When I do #[ApiFilter(SearchFilter::class, properties: ['user'=>'exact'])]
and request ?page=1&user=/api/users/6d4d8ef0-2a8d-416f-b6fb-475d3db0cd0d
It actually calls denormalize method from ApiPlatform\Core\Bridge\Symfony\Identifier\Normalizer\UuidNormalizer
But then it fails somewhere and gives me that error "The identifier id is missing for a query of App\\Entity\\User".
If I request ?page=1&user=6d4d8ef0-2a8d-416f-b6fb-475d3db0cd0d
It returns {..."hydra:totalItems": 0,...}.
When I do #[ApiFilter(SearchFilter::class, properties: ['user.uuid'=>'exact'])]
and request ?page=1&user.uuid=6d4d8ef0-2a8d-416f-b6fb-475d3db0cd0d
It never calls denormalize method from ApiPlatform\Core\Bridge\Symfony\Identifier\Normalizer\UuidNormalizer
and then returns:
{
...
"hydra:member": [],
"hydra:totalItems": 0,
...
}
These are my identifier in entity class:
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[ApiProperty(['identifier' => false])]
private ?int $id;
#[ORM\Column(type: 'uuid', unique: true)]
#[ApiProperty(['identifier' => true])]
#[Groups(['user:read', 'user:write', 'filter:read'])]
private UuidV4 $uuid;
I use use Symfony\Component\Uid\UuidV4; for uuid generation which are stored in db as binary(16).
I tried filtering by fields with string type and it works fine.
Same issue in 2.6.8 experienced today. A fix would be great.
Same issue here. Issue still exist in 2.7.
Similar issue here. My related entity has an id field that is named entityId and that seems to throw off the filter with IRI, but the int works fine
Doesn't work: /api/parents?page=1&child=%2Fapi%childs%2F3 "Can't get a way to read the property "id" in class "Proxies\CG\App\Entity\Child"."
Works: /api/parents?page=1&child=3
entityId property in my Child entity is set with #[ApiProperty(['identifier' => true])]
The only workaround I found to use the IRI was to create a getId getter
public function getId(): ?int
{
return $this->entityId;
}
@soyuka maybe something that could sneak into 2.7? Given that it also exists in 2.7 alpha #4613
I might be completely off here, but I think I found part of the problem. Not sure how to change and test it though.
/api-platform/core/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php Line 127: $associationFieldIdentifier = 'id';
Should that be hardcoded? Thinking that it might need to look at the target class identifier property.
Again, I did not dig really deep into this because there's an "easy" workaround for me.
There's work that needs to be done on the SearchFilter regarding this issue (hardcoded id and things like this). The work we've done on 2.7-3.0 is meant to be able to fix these issues properly. Still, this is not a priority for now. Please create your own filter, it'll be way easier then trying to work around ours. You can still use the SearchFitler on top of your own IdentityFilter.
You can still use the SearchFitler on top of your own IdentityFilter.
That's a bit hard since it became final and the Filters are generated services. Can't decorate it
Decoration does not have anything to do with a class being final. (Extending is not Decorating!)
You decorate by having a class which gets the decorated class as argument and thus will be an instance of the final class. You implement your logic and pass through anything, non-related to your stuff, into the decorated class instance, or call Methods of the decorated class instance in preferred order and/or change data before or after. Only thing you must assure by decorating is to provide the same interface as the decorated class.
Symfony DI will replace the original service when using decoration with yours and provide the decorated instance as argument to your class https://symfony.com/doc/current/service_container/service_decoration.html
After reading my previous comment I realize I sounded snarky so let me try again. Keep in mind I'm replying to "You can still use the SearchFilter on top of your own IdentityFilter."
As @Blackskyliner kindly explained this calls for decorating the service (you don't decorate a class but a service).
For example let's take this Resource:
#[ApiResource]
#[ApiFilter(SearchFilter::class, properties: ['eventTravelBlock' => 'exact'])]
#[ORM\Entity(repositoryClass: EventFlightTravelLegRepository::class)]
class EventFlightTravelLeg extends EventTravelLeg
{
#[ORM\ManyToOne(targetEntity: EventTravelBlock::class)]
#[ORM\JoinColumn(nullable: false)]
public EventTravelBlock $eventTravelBlock;
}
#[ApiResource]
#[ORM\Entity(repositoryClass: EventTravelBlockRepository::class)]
class EventTravelBlock
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[ApiProperty(identifier: false)]
public ?int $id = null;
#[ORM\Column(type: 'uuid')]
#[ApiProperty(identifier: true)]
#[SerializedName(serializedName: 'id')]
public UuidInterface $uuid;
}
Api-Platform generates AnnotationFilterPass.php a annotated_app_entity_event_flight_travel_leg_api_platform_core_bridge_doctrine_orm_filter_search_filter service.
<service id="annotated_app_entity_event_flight_travel_leg_api_platform_core_bridge_doctrine_orm_filter_search_filter" class="ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter" autowire="true">
<tag name="api_platform.filter"/>
<argument type="service" id="doctrine"/>
<argument>null</argument>
<argument type="service" id="api_platform.iri_converter"/>
<argument type="service" id="property_accessor"/>
<argument type="service" id="monolog.logger" on-invalid="ignore"/>
<argument type="collection">
<argument key="eventTravelBlock">exact</argument>
</argument>
<argument type="service" id="api_platform.identifiers_extractor.cached" on-invalid="ignore"/>
<argument type="service" id="serializer.name_converter.metadata_aware" on-invalid="ignore"/>
</service>
So let's decorate that:
App\Doctrine\ORM\Filter\SearchFilter:
decorates: '@annotated_app_entity_event_flight_travel_leg_api_platform_core_bridge_doctrine_orm_filter_search_filter'
<?php
namespace App\Doctrine\ORM\Filter;
use ApiPlatform\Core\Bridge\Doctrine\Common\Filter\SearchFilterInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\ContextAwareFilterInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter as BaseSearchFilter;
use Doctrine\ORM\QueryBuilder;
class SearchFilter implements SearchFilterInterface, ContextAwareFilterInterface
{
public function __construct(
private readonly BaseSearchFilter $decorated,
)
{
}
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null, array $context = [])
{
// TODO: Implement apply() method.
}
public function getDescription(string $resourceClass): array
{
// TODO: Implement getDescription() method.
}
}
How do I continue to build "on top" of the existing SearchFilter?
The FilterInterface only has apply and getDescription so I cannot access any of the properties (['eventTravelBlock' => 'exact']) that were given to the decorated SearchFilter.
Ontopic: If I remember correctly the issue here is that my identifier is a separate uuid property and the SearchFilter calls IriConverter::getItemFromIri() with ['fetch_data' => 'false'] SearchFilterTrait.php#L123
I added a CompilerPass to replace the IriConverter in the SearchFilters with my own AlwaysFetchingDataIriConverter.
AlwaysFetchingDataIriConverter
<?php
namespace App\Doctrine\Filter;
use ApiPlatform\Core\Api\IriConverterInterface;
use ApiPlatform\Core\Api\UrlGeneratorInterface;
class AlwaysFetchingDataIriConverter implements IriConverterInterface
{
public function __construct(
private readonly IriConverterInterface $iriConverter,
)
{
}
public function getItemFromIri(string $iri, array $context = [])
{
$context['fetch_data'] = true;
return $this->iriConverter->getItemFromIri($iri, $context);
}
public function getIriFromItem($item, int $referenceType = UrlGeneratorInterface::ABS_PATH): string
{
return $this->iriConverter->getIriFromItem($item, $referenceType);
}
public function getIriFromResourceClass(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): string
{
return $this->iriConverter->getIriFromResourceClass($resourceClass, $referenceType);
}
public function getItemIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = UrlGeneratorInterface::ABS_PATH): string
{
return $this->iriConverter->getItemIriFromResourceClass($resourceClass, $identifiers, $referenceType);
}
public function getSubresourceIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = UrlGeneratorInterface::ABS_PATH): string
{
return $this->iriConverter->getSubresourceIriFromResourceClass($resourceClass, $identifiers, $referenceType);
}
}
class Kernel extends BrefKernel implements CompilerPassInterface {
// ...
public function process(ContainerBuilder $container): void
{
$this->replaceIriConverterInSearchFilter($container);
}
/** @see https://github.com/api-platform/core/issues/3575 */
private function replaceIriConverterInSearchFilter(ContainerBuilder $container): void
{
$filterServices = $container->findTaggedServiceIds('api_platform.filter');
foreach (array_keys($filterServices) as $serviceId) {
$definition = $container->getDefinition($serviceId);
if (
$definition instanceof ChildDefinition
&& is_a($definition->getParent(), SearchFilterInterface::class, true)
) {
// Replace the $iriConverter with one that always fetches data
$definition->setArgument(
'$iriConverter',
new Reference(AlwaysFetchingDataIriConverter::class)
);
}
}
}
The Linked FilterPass seems to make use of the DI for class resolution and parameter resolution. So should this not work then:
service.yaml
App\Doctrine\ORM\Filter\SearchFilter:
decorates: ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter
arguments:
$decorated: '@.inner'
namespace App\Doctrine\ORM\Filter;
class SearchFilter implements SearchFilterInterface, ContextAwareFilterInterface
{
public function __construct(BaseSearchFilter $decorated, ManagerRegistry $managerRegistry, ?RequestStack $requestStack, AlwaysFetchingDataIriConverter $iriConverter, PropertyAccessorInterface $propertyAccessor = null, LoggerInterface $logger = null, array $properties = null, IdentifiersExtractorInterface $identifiersExtractor = null, NameConverterInterface $nameConverter = null)
{
}
// ...
}
The decorated should get wired in by services.yaml definition and the other stuff should be handled through autowire or explicit FilterPass injections -- or am I missing why this should not work as intended?
Above gives:
The definition "App\Doctrine\ORM\Filter\SearchFilter" has a reference to an abstract definition "api_platform.doctrine.orm.search_filter". Abstract definitions cannot be the target of references.
And
App\Doctrine\ORM\Filter\SearchFilter:
decorates: 'annotated_app_entity_event_flight_travel_leg_api_platform_core_bridge_doctrine_orm_filter_search_filter'
arguments:
$decorated: '@.inner'
Doesn't autowire the $properties argument.
I could add it myself but I would like to avoid repeating myself and/or adding a decorator each time I want to use the SearchFilter.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
I might be completely off here, but can we unblock the situation by updating ApiPlatform\Doctrine\Common\Filter\SearchFilterTrait at line 123 ? (before rework on SearchFilter as @soyuka said)
protected function getIdFromValue(string $value)
{
try {
$iriConverter = $this->getIriConverter();
$item = $iriConverter->getResourceFromIri($value, ['fetch_data' => true]); //$item = $iriConverter->getResourceFromIri($value, ['fetch_data' => false]);
return $this->getPropertyAccessor()->getValue($item, 'id');
} catch (InvalidArgumentException $e) {
// Do nothing, return the raw value
}
return $value;
}
It's working on my side by just overriding this method of the SearchFilterTrait in my own SearchFilter.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
On API Platform 2.5 I did this way. It can certainly be improved
<?php
/*
* @ApiFilter(UuidSearchFilter::class, properties={"author"})
*/
class Books
{
/**
* @ORM\ManyToOne(targetEntity="App\Entity\Author", inversedBy="books")
*/
protected $author;
The filter
<?php
namespace App\Api\Filter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use ApiPlatform\Core\Exception\ItemNotFoundException;
use Doctrine\ORM\QueryBuilder;
final class UuidSearchFilter extends SearchFilter
{
protected function filterProperty(
string $property,
$value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
string $operationName = null
) {
$items = [];
$values = (is_array($value)) ? $value : (array) $value;
foreach ($values as $iri) {
try {
// Get entity from IRI
$items[] = $this->iriConverter->getItemFromIri($iri);
} catch (ItemNotFoundException | InvalidArgumentException $e) {
}
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$parameterName = $queryNameGenerator->generateParameterName($property);
$joinAlias = $queryNameGenerator->generateJoinAlias($property);
// Join the related entity
$queryBuilder->join(sprintf('%s.%s', $rootAlias, $property), $joinAlias);
$queryBuilder
->andWhere($queryBuilder->expr()->in(sprintf('%s.%s', $rootAlias, $property), ':'.$parameterName))
->setParameter($parameterName, $items)
;
}
}
If you follow the doc:
<?php
// api/src/Entity/Offer.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
#[ApiResource]
#[ApiFilter(SearchFilter::class, properties: ['product' => 'exact'])]
class Offer
{
// ...
}
And you don't use Bridge namespace ApiPlatform\Core\Bridge\Doctrine\... like @orangevinz did.
His reply will not work.
Instead, use @nathan-de-pachtere answer as follows:
<?php
namespace App\Doctrine\Filter;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
final class UuidSearchFilter extends SearchFilter
{
protected function getIdFromValue(string $value)
{
try {
$iriConverter = $this->getIriConverter();
$item = $iriConverter->getResourceFromIri($value, ['fetch_data' => true]);
return $this->getPropertyAccessor()->getValue($item, 'id');
} catch (InvalidArgumentException $e) {
// Do nothing, return the raw value
}
return $value;
}
}
And use it like this:
<?php
// api/src/Entity/Offer.php
namespace App\Entity;
+ use App\Doctrine\Filter\UuidSearchFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
- use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
#[ApiResource]
- #[ApiFilter(SearchFilter::class, properties: ['product' => 'exact'])]
+ #[ApiFilter(UuidSearchFilter::class, properties: ['product'])]
class Offer
{
// ...
}