Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Filtering on Relations

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Go directly to /api/users/5.jsonld. This user owns one CheeseListing... and we've decided to embed the title and price fields instead of just showing the IRI. Great!

Earlier, we talked about a really cool filter called PropertyFilter, which allows us to, for example, add ?properties[]=username to the URL if we only want to get back that one field. We added that to CheeseListing, but not User. Let's fix that!

Above User, add @ApiFilter(PropertyFilter::class). And remember, we need to manually add the use statement for filter classes: use PropertyFilter.

... lines 1 - 6
use ApiPlatform\Core\Serializer\Filter\PropertyFilter;
... lines 8 - 15
/**
... lines 17 - 20
* @ApiFilter(PropertyFilter::class)
... lines 22 - 24
*/
class User implements UserInterface
... lines 27 - 190

And... we're done! When we refresh, it works! Other than the standard JSON-LD properties, we only see username.

Selecting Embedded Relation Properties

But wait there's more! Remove the ?properties[]= part for a second so we can see the full response. What if we wanted to fetch only the username property and the title property of the embedded cheeseListings? Is that possible? Totally! You just need to know the syntax. Put back the ?properties[]=username. Now add &properties[, but inside of the square brackets, put cheeseListings. Then []= and the property name: title. Hit it! Nice! Well, the title is empty on this CheeseListing, but you get the idea. The point is this: PropertyFilter kicks butt and can be used to filter embedded data without any extra work.

Speaking of filters, we gave CheeseListing a bunch of them, including the ability to search by title or description and filter by price. Let's add another one.

Scroll to the top of CheeseListing to find SearchFilter. Let's break this onto multiple lines.

... lines 1 - 16
/**
... lines 18 - 34
* @ApiFilter(SearchFilter::class, properties={
* "title": "partial",
* "description": "partial"
* })
... lines 39 - 41
*/
class CheeseListing
... lines 44 - 202

Searching by title and description is great. But what if I want to search by owner: find all the CheeseListings owned by a specific User? Well, we can already do this a different way: fetch that user's data and look at its cheeseListings property. But having it as a filter might be super useful. Heck, then we could search for all cheese listings owned by a specific user and that match some title! And... if users start to have many cheeseListings, we might decide not to expose that property on User at all: the list might be too long. The advantage of a filter is that we can get all the cheese listings for a user in a paginated collection.

To do this... add owner set to exact.

... lines 1 - 16
/**
... lines 18 - 34
* @ApiFilter(SearchFilter::class, properties={
... lines 36 - 37
* "owner": "exact"
* })
... lines 40 - 42
*/
class CheeseListing
... lines 45 - 203

Go refresh the docs and try the GET endpoint. Hey! We've got a new filter box! We can even find by multiple owners. Inside the box, add the IRI - /api/users/4. You can also filter by id, but the IRI is recommended.

Execute and... yes! We get the one CheeseListing for that User. And the syntax on the URL is beautifully simple: ?owner= and the IRI... which only looks ugly because it's URL-encoded.

Searching Cheese Listings by Owner Username

But we can get even crazier! Add one more filter: owner.username set to partial.

... lines 1 - 16
/**
... lines 18 - 34
* @ApiFilter(SearchFilter::class, properties={
... lines 36 - 38
* "owner.username": "partial"
* })
... lines 41 - 43
*/
class CheeseListing
... lines 46 - 204

This is pretty sweet. Refresh the docs again and open up the collection operation. Here's our new filter box, for owner.username. Check this out: Search for "head" because we have a bunch of cheesehead usernames. Execute! This finds two cheese listings owned by users 4 and 5.

Let's fetch all the users... just to be sure and... yep! Users 4 and 5 match that username search. Let's try searching for this cheesehead3 exactly. Put that in the box and... Execute! Got it! The exact search works too. And, even though we're filtering across a relationship, the URL is pretty clean: owner.username=cheesehead3.

Ok just one more short topic for this part of our tutorial: subresources.

Leave a comment!

16
Login or Register to join the conversation
Covi A. Avatar
Covi A. Avatar Covi A. | posted 1 year ago

Hey, many many thanks for this helpful tutorial. it's really helpful.
but now i got a error. and that is when i try to fetch user data then i saw this error. single user and all user both.

"@context": "/api/contexts/Error",
"@type": "hydra:Error",
"hydra:title": "An error occurred",
"hydra:description": "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/do..., or limit the maximum serialization depth using the \"enable_max_depth\" option of the Symfony serializer (https://symfony.com/doc/cur....",
"trace": [
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "",
"file": "/home/mono/Projects/Goodness/vendor/api-platform/core/src/Bridge/Doctrine/Orm/Extension/EagerLoadingExtension.php",
"line": 137,
"args": []
},

i try to find the solution from api platform. but still i can't solve it. could you please tell me about eager-loading. and how can i solve it.

here is my user.php code.


namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Serializer\Filter\PropertyFilter;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

/**
* @ORM\Entity(repositoryClass=UserRepository::class)
* @ApiResource(
* normalizationContext={"groups"={"user:read"}},
* denormalizationContext={"groups"={"user:write"}},
* )
* @ApiFilter(PropertyFilter::class)
* @UniqueEntity(fields={"username"})
* @UniqueEntity(fields={"email"})
*/
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;

/**
* @ORM\Column(type="string", length=180, unique=true)
* @Groups({"user:read","user:write"})
* @Assert\NotBlank()
* @Assert\Email()
*/
private $email;

/**
* @ORM\Column(type="json")
*/
private $roles = [];


/**
* @var string The hashed password
* @ORM\Column(type="string")
* @Groups({"user:write"})
*/
private $password;

/**
* @ORM\Column(type="string", length=255, unique=true)
* @Groups({"user:read","user:write","products:item:get","products:write"})
* @Assert\NotBlank()
*/
private $username;

/**
* @ORM\OneToMany(targetEntity=Product::class, mappedBy="owner", cascade={"persist"}, orphanRemoval=true)
* @Groups({"user:read","user:write"})
*/
private $products;

public function __construct()
{
$this->products = new ArrayCollection();
}

public function getId(): ?int
{
return $this->id;
}

public function getEmail(): ?string
{
return $this->email;
}

public function setEmail(string $email): self
{
$this->email = $email;

return $this;
}

/**
* A visual identifier that represents this user.
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}

/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';

return array_unique($roles);
}

public function setRoles(array $roles): self
{
$this->roles = $roles;

return $this;
}

/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): string
{
return $this->password;
}

public function setPassword(string $password): self
{
$this->password = $password;

return $this;
}

/**
* Returning a salt is only needed, if you are not using a modern
* hashing algorithm (e.g. bcrypt or sodium) in your security.yaml.
*
* @see UserInterface
*/
public function getSalt(): ?string
{
return null;
}

/**
* @see UserInterface
*/
public function eraseCredentials()
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}

public function getUsername(): ?string
{
return $this->username;
}

public function setUsername(string $username): self
{
$this->username = $username;

return $this;
}

/**
* @return Collection|Product[]
*/
public function getProducts(): Collection
{
return $this->products;
}

public function addProduct(Product $product): self
{
if (!$this->products->contains($product)) {
$this->products[] = $product;
$product->setOwner($this);
}

return $this;
}

public function removeProduct(Product $product): self
{
if ($this->products->removeElement($product)) {
// set the owning side to null (unless already changed)
if ($product->getOwner() === $this) {
$product->setOwner(null);
}
}

return $this;
}
}

and here is product.php (like CheeseListing.php)


namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\ProductRepository;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Core\Serializer\Filter\PropertyFilter;
use Carbon\Carbon;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;

/**
* @ApiResource(
* collectionOperations={ "get","post"},
* itemOperations={
"get"={
* "normalization_context"={"groups"={"products:read","products:item:get"}}
* },
* "put",
* "delete"
* },
* attributes={
"pagination_items_per_page"=10,
* "formats"={"json","jsonld","html","jsonhal","csv"={"text/csv"}},
* },
* normalizationContext={"groups"={"products:read"},"swagger_defination_name"="Read"},
* denormalizationContext={"groups"={"products:write"},"swagger_defination_name"="Write"}
* )
* @ORM\Entity(repositoryClass=ProductRepository::class)
* @ApiFilter(BooleanFilter::class,properties={"isPublished"})
* @ApiFilter(SearchFilter::class, properties={
* "title": "partial",
* "description": "partial",
* "owner": "exact"
* })
* @ApiFilter(PropertyFilter::class)
*/
class Product
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;

/**
* it is title column
* @ORM\Column(type="string", length=255, unique=true)
* @Groups({"products:read","products:write","user:read","user:write"})
* @Assert\NotBlank()
* @Assert\Length(
* min=5,
* max=40,
* maxMessage="write your title in less then 20 chars"
* )
*/
private $title;

/**
* @ORM\Column(type="integer", nullable=true)
* @Groups({"products:read","products:write","user:read","user:write"})
* @Assert\NotBlank()
*/
private $price;

/**
* @ORM\Column(type="text", nullable=true)
* @Groups({"products:read","products:write","user:read","user:write"})
* @Assert\NotBlank()
*/
private $description;

/**
* @ORM\Column(type="boolean", nullable=true)
* @Groups({"products:read","products:write","user:write","user:read"})
*/
private $isPublished;


public function __construct(string $title)
{
$this->createdAt = new \DateTimeImmutable();
$this->title = $title;
}

/**
* @ORM\Column(type="datetime", nullable=true)
*/
private $createdAt;

/**
* @ORM\ManyToOne(targetEntity=User::class, inversedBy="products",fetch="EAGER")
* @ORM\JoinColumn(nullable=false)
* @Groups({"products:read","products:write","user:read","user:write"})
*/
private $owner;

public function getId(): ?int
{
return $this->id;
}

public function getTitle(): ?string
{
return $this->title;
}

// public function setTitle(string $title): self
// {
// $this->title = $title;
//
// return $this;
// }

public function getPrice(): ?int
{
return $this->price;
}

public function setPrice(?int $price): self
{
$this->price = $price;

return $this;
}

public function getDescription(): ?string
{
return $this->description;
}

/**
* @Groups({"products:read"})
*/
public function getShortDescription(): ?string
{
if(strlen($this->getDescription()) < 20){
return $this->description;
}
return substr($this->getDescription(),0,20).'...';
}

/**
* @SerializedName("details")
*/
public function setDescription(?string $description): self
{
$this->description = $description;

return $this;
}

// public function setTextDescription(?string $description): self
// {
// $this->description = nl2br($description);
//
// return $this;
// }

public function getIsPublished(): ?bool
{
return $this->isPublished;
}

public function setIsPublished(?bool $isPublished): self
{
$this->isPublished = $isPublished;

return $this;
}

public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}

/**
* @Groups({"products:read"})
*/
public function getCreatedAtAgo(): string
{
return Carbon::instance($this->getCreatedAt())->diffForHumans();
}

// public function setCreatedAt(?\DateTimeInterface $createdAt): self
// {
// $this->createdAt = $createdAt;
//
// return $this;
// }

public function getOwner(): ?User
{
return $this->owner;
}

public function setOwner(?User $owner): self
{
$this->owner = $owner;

return $this;
}
}

i am using symfony version 5.3.0
please help me. sorry the comment so long.

Reply

Hey Covi A.!

I'm really sorry for my very delayed reply - you had a tough question and I've been working on a new library this past week!

So, I believe the problem is basically one of recursion. When you serialize a user, you're using the group user:read. That means the User.product property is serialized. But then, in Product, on the owner property, you also have user:read. This means that it then tries to serialize the "owner" property, which is a User. So, it serializes that User.... which then serializes its User.product property... and so on.. forever. I believe the "too many joins" is basically another way of saying "too much recursion".

The easiest solution is to remove user:read from the Product.owner property. If you fetch a Product directly, the owner would still be included (since it's in the product:read group), but it wouldn't try to serialize recursively anymore.

Let me know if that helps!

Cheers!

Reply
Jean-tilapin Avatar
Jean-tilapin Avatar Jean-tilapin | posted 2 years ago

Hello there!

I'm still learning a lot of stuff lately, and that's great. Thanks SymfonyCast. But could I have some advice on a search problem ?
I have an Article entity on one hand, and a Category (=tag) entity on the other. An article can have many categories. In my search function, I can generate something like :

/api/articles?categories[]=api/categories/1&categories[]=api/categories/2&categories[]=&page=1

which returns all the articles having tag1 and all the articles having tag2. But what I really want, is the articles having both, to narrow the list.
Is there a simple way to do that ? Have I missed some documentation ? Or should I learned how to make Custom Research Filters...? *finger crossed*

Reply

Hey Jean-tilapin!

Really happy we've been useful - keep up the hard work!

> But what I really want, is the articles having both, to narrow the list.

Hmm. Good question! It's a little bit tough to read due to the dynamic nature of the class, but here is the logic behind the search filter: https://github.com/api-plat...

If I'm guessing and reading correctly, you are using the "exact" strategy, which means you are falling into this case - https://github.com/api-plat... - which is a "WHERE IN". That means you're getting something like "WHERE category in (api/categories/1, api/categories/2, api/categories/3)". So.. exactly what you're saying - it will return all articles that are in *any* of these categories.

Looking through the rest of this class, unless I'm missing something, you will not be able to use the SearchFilter out of the box for this. So... you'll need a custom filter. Fortunately, this isn't too hard and we did cover it in a recent tutorial :). Check out https://symfonycasts.com/sc... and the next chapter after.

Let me know if that helps!

Cheers!

Reply
Ian Avatar

Hello,

Unless I have missed something, there is a fairly basic operation that I need to do but can't see how. It would be similar to having something like this in your example.
Request a user (item get), with embedded cheese-listings filtered to isPublished=true.
In other words, get user 1 with his published cheese-listings.
Or /api/users/1?cheese-listings.isPublished=true

In the interface, filters only show up in the get collection pane... which on the face of it seems obvious, but the case I'm describing doesn't seem so far-fetched and I really want to avoid multiple requests to achieve what seems rather trivial.

Thanks!

Reply

Hey Ian!

Yea, it's a pretty good question :). So, the way that you're "supposed" to do this is probably by fetching the User and then fetching the exact cheese listings you need - like GET /api/cheeses?user=/api/users/1&published=1 (or something like that, you get the idea). I know that you've already thought about this (and are trying to avoid the extra HTTP request), but this is basically what ApiPlatform wants you to do.

Check out this video for some rationale about how the embedded resources are loaded - https://symfonycasts.com/sc... - I think it will help highlight what's going on.

I believe the only way for you to accomplish this would be to:

A) Add a publishedCheeseListings field (like I did) by adding a getter method
B) Use the PropertyFilter - https://symfonycasts.com/sc... - as a way to be able to sometimes request this field and sometimes not request it. The only thing I'm not sure about is if there is a way to NOT include the field by default, and only include it IF it's requested via the PropertyFilter (I know it's possible to return it by default and then *avoid* returning it via the PropertyFilter, just not sure if you can do *add* fields via PropertyFilter).

Anyways, let me know if that helps :).

Cheers!

Reply
Stefan L. Avatar
Stefan L. Avatar Stefan L. | posted 2 years ago

hi, is there a way to filter the subresources while fetching the main resource

For example:
All Items:


{
[
brand: 'audi',
colors: [
{name: blue},
{name: green}
]
}

Is there a way to filter the results of a subresource, so the result would be following?


{
[
brand: 'audi',
colors: [
{name: blue}
]
}
Reply

Hey Stefan L.

Have you tried configuring the filter like this?


@ApiFilter(SearchFilter::class, properties={"brand.colors": "exact"})

Cheers!

Reply
Stefan L. Avatar

Isn't this only for filtering the main Resource, so for example it will return all brands where a specific color is included, but it will filter the brands, not the subresource colors?

Reply

Hey Stefan L.!

Yea, I believe also that this would filter the main resource, not the colors property. So, the overall issue is how API Platform loads the colors property. The logic looks like this:

A) API Platform makes an initial query for the main resource. In this example, let's pretend the main resource is for "cars". So, when you GET /api/cars, it queries for all the car resources. If you have any filters applied (e.g. ?brand=audi) then those are used to *modify* the query.

B) To return the "colors" property for each car, API Platform simple calls $car->getColors() on each Car object. And so, you can see why you would *always* get *all* the colors returned.

So if you want to filter that sub-collection, you probably need to do it (more or less) as a custom field. You *could* still expose your "colors" field as a normal field. However, you would then probably need a custom normalizer for the Car resource so that you could *modify* this field dynamically. The process would be similar to when we add a completely custom field in the next tutorial - https://symfonycasts.com/sc... - the difference would be that you aren't really *adding* a new field. Instead, you would read the Request query parameter and, if it exists, you would change the "colors" property to a different value.

Let me know if that makes sense! Often, the more natural way to do something like this is to make a request to /api/colors?car=/api/cars/5&color=blue. In other words, make a direct query to the resource you want to filter. However, I realize that under certain situations, this isn't ideal - so what you're trying to do isn't wrong - just showing how it might work more easily in some situations.

Cheers!

Reply
Stefan L. Avatar

Yes, it makes sense to me, thanks :)

Unfortunately I have to use this query on a list, so /api/colors?car=/api/cars/5&color=blue would only work for 1 item and there would also be cars with no color but they have to be visible too.

I am trying to solve it with a custom field, thank you very much :)

Reply
Daniel K. Avatar
Daniel K. Avatar Daniel K. | posted 2 years ago

Hi, i have this config in my Entities


* @ApiResource(
* collectionOperations={
* "get",
* },
* itemOperations={
* "get",
* "delete"={
* "controller"=NotFoundAction::class,
* "read"=false,
* "output"=false,
* },
* "enhancement"={
* "method"="GET",
* "normalization_context"={"groups"={"enhancement:read"}},
* },
* }
* )

and i have 'enhancement:read' in almost all fields and in relation also, and i thought that API platform will query DB with joins, but i see in my symfony debugger that to get 5 entities and corresponding related entities api platform is making 9 queries, maybe i'm doing something wrong

Reply

Hi Daniel K.!

Interesting. I don't know the answer to this, but I know where to look. API Platform (I'm guessing you know this part, but just in case - it's not something we talked about in the tutorial) automatically fetches relationships eagerly. The class that does that is this one: https://github.com/api-plat...

I would add some debug code to this class and figure out *where* & why the eager loading is not happening. I can't see anything with your code or that class that would make me expect this behavior.

Let me know what you find out - I'd really be interested!

Cheers!

Reply
MolloKhan Avatar MolloKhan | SFCASTS | posted 3 years ago | edited

Hey there

I didn't try this but looks like you have to declare your filter service first like so:


services:
#...
someEntity.search_filter:
parent: 'api_platform.doctrine.orm.search_filter'
arguments: [ { someProperty: 'strategy' }, {...} ]
...

Then you have to bind that service to your ApiResource (entity), and then it should work. You can find more info about configuring filters here: https://api-platform.com/do...

I hope this helps. Cheers!

Reply
Ajie62 Avatar

Thanks Diego! Sorry, I removed by comment because I didn't see your answer! :/ I managed to find a solution before seeing your comment and it seems that you were right. I posted the whole solution here: https://stackoverflow.com/q... Maybe it will help others. ;)

1 Reply

NP Jerome! and thanks for sharing your solution :)

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.3
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.10.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.5
        "nesbot/carbon": "^2.17", // 2.19.2
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/console": "4.2.*", // v4.2.12
        "symfony/dotenv": "4.2.*", // v4.2.12
        "symfony/expression-language": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/flex": "^1.1", // v1.17.6
        "symfony/framework-bundle": "4.2.*", // v4.2.12
        "symfony/security-bundle": "4.2.*|4.3.*", // v4.3.3
        "symfony/twig-bundle": "4.2.*|4.3.*", // v4.2.12
        "symfony/validator": "4.2.*|4.3.*", // v4.3.11
        "symfony/yaml": "4.2.*" // v4.2.12
    },
    "require-dev": {
        "symfony/maker-bundle": "^1.11", // v1.11.6
        "symfony/stopwatch": "4.2.*|4.3.*", // v4.2.9
        "symfony/web-profiler-bundle": "4.2.*|4.3.*" // v4.2.9
    }
}