Query Extension: Auto-Filter a Collection
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeThe CheeseListing
entity has a property on it called $isPublished
, which defaults to false
. We haven't talked about this property much, but the idea is pretty simple: when a CheeseListing
is first created, it will not be published. Then, once a user has perfected all their cheesy details, they will "publish" it and only then will it be publicly available on the site.
This means that we have some work to do! We need to automatically filter the CheeseListing
collection to only show published items.
Adding a Test
Let's put this into a simple test first. Inside CheeseListingResourceTest
add public function testGetCheeseListingCollection()
and then go to work: $client = self::createClient()
and $user = $this->createUser()
with any email and password.
// ... lines 1 - 9 | |
class CheeseListingResourceTest extends CustomApiTestCase | |
{ | |
// ... lines 12 - 75 | |
public function testGetCheeseListingCollection() | |
{ | |
$client = self::createClient(); | |
$user = $this->createUser('cheeseplese@example.com', 'foo'); | |
// ... lines 80 - 103 | |
} | |
} |
We're not logging in as this user simply because the CheeseListing
collection operation doesn't require authentication. Now, let's make some cheese! I'll create $cheeseListing1
... with a bunch of data... then use that to create $cheeseListing2
... and $cheeseListing3
. These are nice, boring, delicious CheeseListing
objects. To save them, grab the entity manager - $em = $this->getEntityManager()
- persist all three... and call flush()
.
// ... lines 1 - 75 | |
public function testGetCheeseListingCollection() | |
{ | |
// ... lines 78 - 80 | |
$cheeseListing1 = new CheeseListing('cheese1'); | |
$cheeseListing1->setOwner($user); | |
$cheeseListing1->setPrice(1000); | |
$cheeseListing1->setDescription('cheese'); | |
$cheeseListing2 = new CheeseListing('cheese2'); | |
$cheeseListing2->setOwner($user); | |
$cheeseListing2->setPrice(1000); | |
$cheeseListing2->setDescription('cheese'); | |
$cheeseListing3 = new CheeseListing('cheese3'); | |
$cheeseListing3->setOwner($user); | |
$cheeseListing3->setPrice(1000); | |
$cheeseListing3->setDescription('cheese'); | |
$em = $this->getEntityManager(); | |
$em->persist($cheeseListing1); | |
$em->persist($cheeseListing2); | |
$em->persist($cheeseListing3); | |
$em->flush(); | |
// ... lines 101 - 103 | |
} | |
// ... lines 105 - 106 |
The stage for our test is set. Because the default value for the isPublished
property is false... all of these new listings will be unpublished. Fetch these by using $client->request()
to make a GET
request to /api/cheeses
.
// ... lines 1 - 75 | |
public function testGetCheeseListingCollection() | |
{ | |
// ... lines 78 - 101 | |
$client->request('GET', '/api/cheeses'); | |
// ... line 103 | |
} | |
// ... lines 105 - 106 |
Because we haven't added any logic to hide unpublished listings yet, at this moment, we would expect this to return 3 results. Move over to the docs... and try the operation. Ah, cool! Hydra adds a very useful field: hydra:totalItems
. Let's use that! In the test, $this->assertJsonContains()
that there will be a hydra:totalItems
field set to 3
.
// ... lines 1 - 75 | |
public function testGetCheeseListingCollection() | |
{ | |
// ... lines 78 - 102 | |
$this->assertJsonContains(['hydra:totalItems' => 3]); | |
} | |
// ... lines 105 - 106 |
Copy the method name and flip over to your terminal: this test should pass because we have not added any filtering yet. Try it:
php bin/phpunit --filter=testGetCheeseListingCollection
And... green! I love when there are no surprises.
Now let's update the test for the behavior that we want. Allow $cheeseListing1
to stay not published, but publish the other two: $cheeseListing2->setIsPublished(true)
... and then paste that below and rename it for $cheeseListing3
.
Once we're done with the feature, we will only want the second two to be returned. Change the assertion to 2. Now we have a failing test.
// ... lines 1 - 75 | |
public function testGetCheeseListingCollection() | |
{ | |
// ... lines 78 - 89 | |
$cheeseListing2->setIsPublished(true); | |
// ... lines 91 - 95 | |
$cheeseListing3->setIsPublished(true); | |
// ... lines 97 - 104 | |
$this->assertJsonContains(['hydra:totalItems' => 2]); | |
} | |
// ... lines 107 - 108 |
Automatic Filtering
So... how can we make this happen? How can we hide unpublished cheese listings? Well... in the first tutorial we talked about filters. For example, we added a boolean filter that allows us to add a ?isPublished=1
or ?isPublished=0
query parameter to the /api/cheeses
operation. So... actually, an API client can already filter out unpublished cheese listings!
That's great! The problem is that we no longer want an API client to be able to control this: we need this filter to be automatic. An API client should never see unpublished listings. Filters are a good choice for optional stuff, but not this.
Another option is called a Doctrine filter. It's got a similar name, but is a totally different feature that comes from Doctrine - we talk about it in our Doctrine Queries tutorial. A Doctrine filter allows you to modify a query on a global level... like each time we query for a CheeseListing
, we could automatically add WHERE is_published=1
to that query.
The downside to a Doctrine filter.... is also its strength: it's automatic - it modifies every query you make... which can be surprising if you ever need to purposely query for all cheese listings. Sure, you can work around that - but I tend to use Doctrine filters rarely.
The solution I prefer is specific to API Platform... which I love because it means that the filtering will only affect API operations... the rest of my app will continue to behave normally. It's called a "Doctrine extension".... which is basically a "hook" for you to change how a query is built for a specific resource or even for a specific operation of a resource. They're... basically... awesome.
Creating the Query Collection Extension
Let's get our extension going: in the src/ApiPlatform/
directory, create a new class called CheeseListingIsPublishedExtension
. Make this implement QueryCollectionExtensionInterface
and then go to the Code -> Generate menu - or Command + N on a Mac - and select "Implement Methods" to generate the one method that's required by this interface: applyToCollection()
.
// ... lines 1 - 4 | |
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; | |
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; | |
use Doctrine\ORM\QueryBuilder; | |
// ... line 8 | |
class CheeseListingIsPublishedExtension implements QueryCollectionExtensionInterface | |
{ | |
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) | |
{ | |
} | |
} |
Very simply: as soon as we create a class that implements this interface, every single time that API Platform makes a Doctrine query for a collection of results, like a collection of users or a collection of cheese listings, it will call this method and pass us a pre-built QueryBuilder
. Then... we can mess with that query however we want!
Tip
If you're loading data from something other than Doctrine (or have a custom "data provider"... a topic we haven't talked about yet), then this class won't have any effect.
In fact, this is how pagination and filters work internally: both are implemented as Doctrine extensions.
Query Collection Extension Logic
Let's see... even though this method will be called whenever API Platform is querying for a collection of any type of object, we only want to do our logic for cheese listings. No problem: if $resourceClass !== CheeseListing::class
return and do nothing.
// ... lines 1 - 11 | |
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) | |
{ | |
if ($resourceClass !== CheeseListing::class) { | |
return; | |
} | |
// ... lines 17 - 20 | |
} |
Now... we just need to add the WHERE isPublished=1
part to the query. To do that will require a little bit of fanciness. Start with $rootAlias = $queryBuilder->getRootAlias()[0]
. I'll explain that in a minute. Then $queryBuilder->andWhere()
with sprintf('%s.isPublished = :isPublished')
passing $rootAlias
to fill in that %s
part. For the isPublished
parameter, set it with ->setParameter('isPublished', true)
.
// ... lines 1 - 11 | |
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) | |
{ | |
// ... lines 14 - 17 | |
$rootAlias = $queryBuilder->getRootAliases()[0]; | |
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished', $rootAlias)) | |
->setParameter('isPublished', true); | |
} |
This mostly looks like normal query logic... the weird part being that "root alias" thing. Because someone else originally created this query, we don't know what "table alias" they're using to represent CheeseListing
. Maybe c
... so c.isPublished
? Or cheese
... so cheese.isPublished
? We don't really know. The getRootAlias()
method tells us that... and then we hack it into our query.
Oh, and by the way - in addition to $resourceClass
, this method receives the $operationName
. Normally only the get
operation - like GET /api/cheese
- would cause a query for a collection to be made... but if you created some custom operations and needed to tweak the query on an operation-by-operation basis, the $operationName
can let you do that.
Anyways... we should be done! Run that test again:
php bin/phpunit --filter=testGetCheeseListingCollection
And... green! We are successfully hiding the unpublished cheese listings. Isn't that nice?
Next, let's add logic so that admin users will still see unpublished cheese listings. And what about the item operation? Sure, an unpublished cheese listing won't be included in the collection endpoint... but how can we prevent a user from making a GET request directly to fetch a single, unpublished CheeseListing
?
Hi all,
I have added a Query Extension to add an autofilter to my unpublised content, as in your tutorial and it works GREAT.
Thanks for your work, first of all!
Let you have an endpoint that show an entity WITH related resources; let we want to apply the same business rule ( hide unpublished content ) also on related resource ( es: a company endpoint that show related products, loaded lazy as doctrine entities )
Actually, I have create a Normalizer related to the main resource and I loop over related resource and I skip di unpublished content.
Do you know other way to hide subresources?
I think there is no other way to perform this autofilter ALSO on related resource