Writing reusable queries with QueryTypes

By Mario Blažek -

In this blog post, we are going to explore the possibilities of writing well-structured query objects rather than polluting controllers with unnecessary and duplicated code.

Since the early days of eZ Publish 5 and the following eZ Platform, developers were advised to write queries in controllers in order to comply with modern MVC paradigms of separating concerns, the process called enriching the view, rather than writing all the complicated fetch logic in templates like it was the case with eZ Publish 4. 

In this blog post, we are going to explore the possibilities of writing well-structured query objects rather than polluting controllers with unnecessary and duplicated code.

All the code examples will be run in the context of Netgen’s Media site so I suggest you install it locally and follow along. Please consult the installation manual.

The problem

For every Recipe content, we want to output the four latest related recipe content items. Let’s use /recipes/sweet-potato-and-black-bean-veggie-burgers recipe as a testing object.

In most cases, the simple workflow would look like this:

  1. Create the controller and method to enrich the full view
  2. Write query logic in the controller
  3. Add a controller to view configuration
  4. Write a template code to display recipes

This is our starting code. The src\AppBundle\Controller\FullViewController:

<?php

namespace AppBundle\Controller;

use Netgen\Bundle\EzPlatformSiteApiBundle\Controller\Controller;
use Netgen\Bundle\EzPlatformSiteApiBundle\View\ContentView;
use Netgen\TagsBundle\API\Repository\Values\Content\Query\Criterion\TagId;
use Netgen\TagsBundle\API\Repository\Values\Tags\Tag;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause;
use eZ\Publish\API\Repository\Values\Content\LocationQuery;

class FullViewController extends Controller
{
    public function viewRecipe(ContentView $view)
    {
        $content = $view->getSiteContent();
        if ($content->hasField('tags') && !$content->getField('tags')->isEmpty()) {
            $tags = $content->getFieldValue('tags');
            $tagsIds = array_map(
                function(Tag $tag) {
                    return $tag->id;
                },
                $tags->tags
            );
            $criteria = [
                new Criterion\Visibility(Criterion\Visibility::VISIBLE),
                new Criterion\ContentTypeIdentifier('ng_recipe'),
                new TagId($tagsIds),
                new Criterion\LogicalNot(
                    new Criterion\ContentId($content->id)
                )
            ];
            $query = new LocationQuery();
            $query->limit = 4;
            $query->filter = new Criterion\LogicalAnd($criteria);
            $query->sortClauses = [
                new SortClause\DatePublished(LocationQuery::SORT_DESC),
            ];
            $recipes = $this->getSite()
                ->getFilterService()
                ->filterLocations($query);
            $view->addParameters([
                'recipes' => $this->extractValueObjects($recipes),
            ]);
        }

        return $view;
    }
}

View configuration in src\AppBundle\Resources\config\content_view.yml:

ng_recipe:
    template: "@ezdesign/content/full/ng_recipe.html.twig"
    controller: "app.controller.full:viewRecipe"
    match:
        Identifier\ContentType: ng_recipe

And our template code in src\AppBundle\Resources\views\themes\app\content\full\ng_recipe.html.twig:

{% if recipes is defined and recipes is not empty %}
    <h2 class="section-title section-title-centered title">Latest recipes</h2>

    {% for recipe in recipes %}
        {{ render(
            controller(
            'ng_content:viewAction', {
                'content': recipe.content,
                'location': recipe,
                'viewType': 'standard',
            }
            )
    ) }}
    {% endfor %}
{% endif %}

Use QueryTypes

In this step, we will introduce QueryTypes - classes that build and return Query objects. So first let’s create the LatestRelatedRecipes, then implement QueryType interface and put it under Queries namespace.

<?php

namespace AppBundle\Queries;

use eZ\Publish\Core\QueryType\QueryType;
use Netgen\EzPlatformSiteApi\API\LoadService;
use Netgen\TagsBundle\API\Repository\Values\Content\Query\Criterion\TagId;
use Netgen\TagsBundle\API\Repository\Values\Tags\Tag;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause;
use eZ\Publish\API\Repository\Values\Content\LocationQuery;

final class LatestRelatedRecipes implements QueryType
{
    /**
     * @var \Netgen\EzPlatformSiteApi\API\LoadService
     */
    protected $loadService;

    public function __construct(LoadService $loadService)
    {
        $this->loadService = $loadService;
    }

    public function getQuery(array $parameters = [])
    {
        if (!isset($parameters['recipe_content_id'])) {
            throw new \InvalidArgumentException('Parameter recipe_content_id must be provided');
        }

        $content = $this->loadService->loadContent(
            $parameters['recipe_content_id']
        );

        $limit = 4;
        if (isset($parameters['limit'])) {
            $limit = $parameters['limit'];
        }

        $criteria = [
            new Criterion\Visibility(Criterion\Visibility::VISIBLE),
            new Criterion\ContentTypeIdentifier(
                $content->contentInfo->contentTypeIdentifier
            ),
            new Criterion\LogicalNot(
                new Criterion\ContentId($content->id)
            )
        ];

        if ($content->hasField('tags') && !$content->getField('tags')->isEmpty()) {
            $tags = $content->getFieldValue('tags');
            $tagsIds = array_map(
                function(Tag $tag) {
                    return $tag->id;
                },
                $tags->tags
            );
            $criteria[] = new TagId($tagsIds);
        }
        $query = new LocationQuery();
        $query->limit = $limit;
        $query->filter = new Criterion\LogicalAnd($criteria);
        $query->sortClauses = [
            new SortClause\DatePublished(LocationQuery::SORT_DESC),
        ];

        return $query;
    }

    public function getSupportedParameters()
    {
        return [
            'recipe_content_id', 'limit'
        ];
    }

    public static function getName()
    {
        return 'LatestRelatedRecipes';
    }
}

 
Any class named <Bundle>\QueryType\*QueryType that implements the QueryType interface will be registered as a custom QueryType, but we don’t want this now, so let’s write service configuration manually.

 Service definition:

app.queries.lastest_related_recipes:
    class: AppBundle\Queries\LatestRelatedRecipes
    arguments:
        - '@netgen.ezplatform_site.load_service'
    tags:
        - { name: ezpublish.query_type }

Notice the service container tag. Every QueryType must be tagged with the ezpublish.query_type tag.

And now slim down controller method, which becomes more readable.

Controller:

<?php

namespace AppBundle\Controller;

use AppBundle\Queries\LatestRelatedRecipes;
use Netgen\Bundle\EzPlatformSiteApiBundle\Controller\Controller;
use Netgen\Bundle\EzPlatformSiteApiBundle\View\ContentView;

class FullViewController extends Controller
{
    public function viewRecipe(ContentView $view)
    {
        $queryType = $this->get('ezpublish.query_type.registry')
            ->getQueryType(LatestRelatedRecipes::getName());

        $query = $queryType->getQuery([
            'recipe_content_id' => $view->getSiteContent()->id,
            'limit' => 4,
        ]);

        $recipes = $this->getSite()
            ->getFilterService()
            ->filterLocations($query);

        $view->addParameters([
            'recipes' => $this->extractValueObjects($recipes),
        ]);

        return $view;
    }
}

In order to get our QueryType, we must get QueryTypeRegistry service from container first and then we simply call getQueryType() method, passing in the actual name of our QueryType. The name is defined in getName() static method in our custom implementation of QueryType.

Simple improvement - use OptionsResolverBasedQueryType

In the previous example of QueryType, our responsibility was to check if the required parameters were passed in. This is quite an error-prone way that can lead to serious bugs. It would be much better if we define the required parameters, its types and default value via OptionsResolver class. OptionsResolver comes from Symfony’s excellent OptionsResolver component. I highly recommend to skim the docs and get familiar with it.

To work with OptionsResolver smoothly, the eZ developers wrote OptionsResolverBasedQueryType abstract class, which makes usage of OptionsResolver quite easy. Let's update our QueryType code:

<?php

namespace AppBundle\Queries;

use eZ\Publish\Core\QueryType\OptionsResolverBasedQueryType;
use Netgen\EzPlatformSiteApi\API\LoadService;
use Netgen\TagsBundle\API\Repository\Values\Content\Query\Criterion\TagId;
use Netgen\TagsBundle\API\Repository\Values\Tags\Tag;
use eZ\Publish\API\Repository\Values\Content\Query\Criterion;
use eZ\Publish\API\Repository\Values\Content\Query\SortClause;
use eZ\Publish\API\Repository\Values\Content\LocationQuery;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class LatestRelatedRecipes extends OptionsResolverBasedQueryType
{
    /**
     * @var \Netgen\EzPlatformSiteApi\API\LoadService
     */
    protected $loadService;

    public function __construct(LoadService $loadService)
    {
        $this->loadService = $loadService;
    }

    protected function doGetQuery(array $parameters)
    {
        $content = $this->loadService->loadContent(
            $parameters['recipe_content_id']
        );

        $criteria = [
            new Criterion\Visibility(Criterion\Visibility::VISIBLE),
            new Criterion\ContentTypeIdentifier(
               $content->contentInfo->contentTypeIdentifier
            ),
            new Criterion\LogicalNot(
                new Criterion\ContentId($content->id)
            )
        ];

        if ($content->hasField('tags') && !$content->getField('tags')->isEmpty()) {
            $tags = $content->getFieldValue('tags');
            $tagsIds = array_map(
                function(Tag $tag) {
                    return $tag->id;
                },
                $tags->tags
            );
            $criteria[] = new TagId($tagsIds);
        }
        $query = new LocationQuery();
        $query->limit = $parameters['limit'];
        $query->filter = new Criterion\LogicalAnd($criteria);
        $query->sortClauses = [
            new SortClause\DatePublished(LocationQuery::SORT_DESC),
        ];

        return $query;
    }

    protected function configureOptions(OptionsResolver $optionsResolver)
    {
        $optionsResolver->setDefined(['recipe_content_id', 'limit']);
        $optionsResolver->setAllowedTypes('recipe_content_id', 'int');
        $optionsResolver->setRequired('recipe_content_id');
        $optionsResolver->setAllowedTypes('limit', 'int');
        $optionsResolver->setDefault('limit', 4);
    }

    public static function getName()
    {
        return 'LatestRelatedRecipes';
    }
}

Notice the configureOptions() method. Now our QueryType looks even cleaner and more elegant.

The QueryController

As you can see by now, we were using our custom controller only to execute the QueryType and return executed query results back to the template. In order to mitigate the need to write a custom controller just to execute queries, eZ provides something called QueryController. The idea is very simple - In the view configuration, define which query type has to be run and which type of query you want to perform:

  • Content - by setting ez_query:contentQueryAction as controller
  • Location - by setting ez_query:locationQueryAction as controller
  • ContentInfo - by setting ez_query:contentInfoQueryAction as controller 

View configuration:

ng_recipe:
    template: "@ezdesign/content/full/ng_recipe.html.twig"
    controller: "ez_query:contentQueryAction"
    match:
        Identifier\ContentType: ng_recipe
    params:
        query:
            query_type: 'LatestRelatedRecipes'
            parameters:
                recipe_content_id: "@=content.id"
            assign_results_to: 'recipes' 

Template:

{% if recipes is defined and recipes.totalCount > 0 %}
    <h2 class="section-title section-title-centered title">Latest recipes</h2>

    {% for recipe in recipes.searchHits %}
        {{ render(
            controller(
                'ez_content:viewAction', {
                'contentId': recipe.id,
                'locationId': recipe.contentInfo.mainLocationId,
                'viewType': 'standard',
                }
            )
            ) }}
    {% endfor %}
{% endif %}

Unfortunately, this will not work as we are using Site API layer for working with Content. Another drawback of the QueryController is that it can execute only one query per view configuration, which works in most cases, but sometimes you have to execute two and, in some advanced use cases, even more queries.

Enter Site API Query Types

Site API Query Types expand upon Query Type feature from eZ Publish Kernel, using the same basic interfaces. That will enable using your existing Query Types, but how Site API integrates them with the rest of the system differs from the original eZ Platform implementation.

Site API provides a big list of predefined Query Types: general purpose ones, Query Types for handling Content relations and Query Types related to Content traversal. For more information about the available Query Types please visit documentation site.

In our case, let’s use Site API Query Type. After doing some research through Site API docs, we found Query Type that can solve all of our problems - the Tag field Content relations Query Type. It was developed with tag relations in mind, the same as in our use case, accepts all the parameters we need like limit, tag field used for relation, content type and sort. All of this you can find in docs.

Now that we have all the required things, so let’s write a new view configuration for the recipe:

ng_recipe:
    template: "@ezdesign/content/full/ng_recipe.html.twig"
    match:
        Identifier\ContentType: ng_recipe
    queries:
        latest_recipes:
            query_type: SiteAPI:Content/Relations/TagFields
            max_per_page: 4
            page: '@=queryParam("page", 1)'
            parameters:
                limit: 4
                relation_field: tags
                content_type: ng_recipe
                sort: published desc

Then, update template code for the recipe to use Site API defined query:

{% set recipes = ng_query( 'latest_recipes' ) %}

{% if recipes.getNbResults > 0 %}
    <h2 class="section-title section-title-centered title">Latest recipes</h2>

    {% for recipe in recipes %}
        {{ render(
            controller(
                'ng_content:viewAction', {
                'content': recipe,
                'location': recipe.mainLocation,
                'viewType': 'standard',
            }
            )
         ) }}
    {% endfor %}
{% endif %}

Code is almost the same as in the previous examples, with the exception of ng_query() Twig function that resolves given query by its name.

Conclusion

We went through the whole process of writing a custom controller, with having enriching the view while writing complete fetch logic in a controller method goal in mind. We have explained the simple improvement of extracting query logic to standalone classes called QueryTypes. While having your own QueryTypes on a project is a good thing, it is even better when you can reuse QueryTypes. With the rock solid and battle-tested list of reusable QueryTypes, SiteAPI again proves as a great addition to our eZ dev toolbox. I’m sure you will think twice before writing query logic in a controller next time.

Comments

This site uses cookies. Some of these cookies are essential, while others help us improve your experience by providing insights into how the site is being used. For more detailed information on the cookies we use, please check our Privacy Policy.