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.

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

The 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?

Leave a comment!

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/api-pack": "^1.2", // v1.2.0
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "nesbot/carbon": "^2.17", // 2.21.3
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.9.6
        "symfony/framework-bundle": "4.3.*", // v4.3.2
        "symfony/http-client": "4.3.*", // v4.3.3
        "symfony/monolog-bundle": "^3.4", // v3.4.0
        "symfony/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // v2.5.1
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/test-pack": "^1.0" // v1.0.6
    }
}