core: translatable not working on item operations

I am using StofDoctrineExtensionsBundle to translate entities. While it is working perfectly on collection operations, it fails on item operations and the returned fields are always in default locale. I use an event subscriber to set the locale:

<?php

namespace MWS\UserBundle\EventSubscriber;

use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class LocaleSubscriber implements EventSubscriberInterface
{
    private $defaultLocale;

    public function __construct($defaultLocale = 'en_US')
    {
        $this->defaultLocale = $defaultLocale;
    }

    public function onKernelRequest(GetResponseEvent $event)
    {
        $request = $event->getRequest();

        // try to see if the locale has been set as a accept-language routing parameter
        if ($locale = $request->headers->get('accept-language')) {
            $request->getSession()->set('_locale', $locale);
            $request->setLocale($locale);
        } else {
            // if no explicit locale has been set on this request, use one from the session
            $request->setLocale($request->getSession()->get('_locale', $this->defaultLocale));
        }
    }

    public static function getSubscribedEvents()
    {
        return array(
            // must be registered after the default Locale listener
            KernelEvents::REQUEST => array(array('onKernelRequest', 15)),
        );
    }
}

About this issue

  • Original URL
  • State: closed
  • Created 6 years ago
  • Comments: 27 (11 by maintainers)

Most upvoted comments

Today I can come up with a solution. The answer lies not in a custom action but in a custom extension described here. You have to set hints for the query described in the documentation of gedmo translatable. This is my implementation:

<?php
namespace MWS\NutritionCalculatorBundle\Doctrine;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Gedmo\Translatable\Query\TreeWalker\TranslationWalker;
use Gedmo\Translatable\TranslatableListener;
use MWS\NutritionCalculatorBundle\Entity\DogBreed;
use Symfony\Component\HttpFoundation\RequestStack;

final class DogBreedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
    /**
     * @var RequestStack
     */
    private $requestStack;

    /**
     * DogBreedExtension constructor.
     * @param RequestStack $requestStack
     */
    public function __construct(RequestStack $requestStack)
    {
        $this->requestStack = $requestStack;
    }

    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
    {
        $this->addHints($queryBuilder, $resourceClass);
    }

    /**
     * @param QueryBuilder $queryBuilder
     * @param QueryNameGeneratorInterface $queryNameGenerator
     * @param string $resourceClass
     * @param array $identifiers
     * @param string|null $operationName
     * @param array $context
     */
    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = [])
    {
        $this->addHints($queryBuilder, $resourceClass);
    }

    /**
     *
     * @param QueryBuilder $queryBuilder
     * @param string       $resourceClass
     */
    private function addHints(QueryBuilder $queryBuilder, string $resourceClass)
    {
        if (DogBreed::class === $resourceClass) {
            $queryBuilder = $queryBuilder->getQuery();
            $queryBuilder->setHint(
                Query::HINT_CUSTOM_OUTPUT_WALKER,
                'Gedmo\\Translatable\\Query\\TreeWalker\\TranslationWalker'
            );
            // locale
            $queryBuilder->setHint(
                TranslatableListener::HINT_TRANSLATABLE_LOCALE,
                $this->requestStack->getCurrentRequest()->getLocale() // take locale from session or request etc.
            );
            // fallback
            $queryBuilder->setHint(
                TranslatableListener::HINT_FALLBACK,
                1 // fallback to default values in case if record is not translated
            );
            $queryBuilder->getResult();
        }
    }
}

Adding hints to the collection request is not necessary, but it gives you one database query for the list view instead of multiple queries depending on your pagination size.

Thanks, @remoteclient, furthermore I had a caching problem with the query in production environment, and I could solve it using the following statement: $queryBuilder = $queryBuilder->getQuery()->useQueryCache(false);

Today I can come up with a solution. The answer lies not in a custom action but in a custom extension described here. You have to set hints for the query described in the documentation of gedmo translatable. This is my implementation:

<?php
namespace MWS\NutritionCalculatorBundle\Doctrine;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Gedmo\Translatable\Query\TreeWalker\TranslationWalker;
use Gedmo\Translatable\TranslatableListener;
use MWS\NutritionCalculatorBundle\Entity\DogBreed;
use Symfony\Component\HttpFoundation\RequestStack;

final class DogBreedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
    /**
     * @var RequestStack
     */
    private $requestStack;

    /**
     * DogBreedExtension constructor.
     * @param RequestStack $requestStack
     */
    public function __construct(RequestStack $requestStack)
    {
        $this->requestStack = $requestStack;
    }

    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
    {
        $this->addHints($queryBuilder, $resourceClass);
    }

    /**
     * @param QueryBuilder $queryBuilder
     * @param QueryNameGeneratorInterface $queryNameGenerator
     * @param string $resourceClass
     * @param array $identifiers
     * @param string|null $operationName
     * @param array $context
     */
    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = [])
    {
        $this->addHints($queryBuilder, $resourceClass);
    }

    /**
     *
     * @param QueryBuilder $queryBuilder
     * @param string       $resourceClass
     */
    private function addHints(QueryBuilder $queryBuilder, string $resourceClass)
    {
        if (DogBreed::class === $resourceClass) {
            $queryBuilder = $queryBuilder->getQuery()->useQueryCache(false);  //  <<======== need this for cache pb
            $queryBuilder->setHint(
                Query::HINT_CUSTOM_OUTPUT_WALKER,
                'Gedmo\\Translatable\\Query\\TreeWalker\\TranslationWalker'
            );
            // locale
            $queryBuilder->setHint(
                TranslatableListener::HINT_TRANSLATABLE_LOCALE,
                $this->requestStack->getCurrentRequest()->getLocale() // take locale from session or request etc.
            );
            // fallback
            $queryBuilder->setHint(
                TranslatableListener::HINT_FALLBACK,
                1 // fallback to default values in case if record is not translated
            );
            $queryBuilder->getResult();
        }
    }
}

Adding hints to the collection request is not necessary, but it gives you one database query for the list view instead of multiple queries depending on your pagination size.

thank you very much , this worked for me

It is worth an entry in the StofDoctrineExtensionsBundle’s or API Platform docs.

I use xml for configuration:

    <services>
        <service id="mws_nc.data_provider.translatable_collection_data_provider"
                 class="MWS\NutritionCalculatorBundle\DataProvider\TranslatableCollectionDataProvider"
        >
            <tag name="api_platform.collection_data_provider" priority="2"/>
            <argument type="service" id="doctrine"/>
            <argument type="service" id="request_stack"/>
            <argument type="service" id="api_platform.metadata.resource.metadata_factory" />
            <argument>%api_platform.collection.pagination.enabled%</argument>
            <argument>%api_platform.collection.pagination.client_enabled%</argument>
            <argument>%api_platform.collection.pagination.client_items_per_page%</argument>
            <argument>%api_platform.collection.pagination.items_per_page%</argument>
            <argument>%api_platform.collection.pagination.page_parameter_name%</argument>
            <argument>%api_platform.collection.pagination.enabled_parameter_name%</argument>
            <argument>%api_platform.collection.pagination.items_per_page_parameter_name%</argument>
            <argument>%api_platform.collection.pagination.maximum_items_per_page%</argument>
            <argument>%api_platform.collection.pagination.partial%</argument>
            <argument>%api_platform.collection.pagination.client_partial%</argument>
            <argument>%api_platform.collection.pagination.partial_parameter_name%</argument>
            <argument type="collection">
                <argument type="service" id="api_platform.doctrine.orm.query_extension.eager_loading"/>
                <argument type="service" id="api_platform.doctrine.orm.query_extension.filter"/>
                <argument type="service" id="api_platform.doctrine.orm.query_extension.filter_eager_loading"/>
                <argument type="service" id="api_platform.doctrine.orm.query_extension.order"/>
                <argument type="service" id="api_platform.doctrine.orm.query_extension.pagination"/>
            </argument>
        </service>

It’s working 👍 Thanks a lot!

I have a custom Provider for that. You can make the support function more generic:

<?php
/**
 * Class TranslatableDataProvider.
 *
 * @author Martin Walther <martin@myweb.solutions>
 *
 * (c) MyWebSolutions
 */

declare(strict_types=1);

namespace MWS\NutritionCalculatorBundle\DataProvider;

use ApiPlatform\Core\Bridge\Doctrine\Orm\AbstractPaginator;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\PaginationExtension;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Paginator;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryChecker;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGenerator;
use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
use ApiPlatform\Core\Exception\ResourceClassNotSupportedException;
use ApiPlatform\Core\Exception\RuntimeException;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrineOrmPaginator;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager;
use Gedmo\Translatable\TranslatableListener;
use MWS\NutritionCalculatorBundle\Entity\DogBreed;
use MWS\NutritionCalculatorBundle\Entity\NutritionDatabase;
use MWS\NutritionCalculatorBundle\Entity\NutritionDatabaseMetadata;
use MWS\NutritionCalculatorBundle\Entity\NutritionDatabaseMetadataClassification;
use MWS\NutritionCalculatorBundle\Entity\NutritionDatabaseMetadataDimension;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Traversable;

class TranslatableCollectionDataProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
    private $managerRegistry;
    private $requestStack;
    private $resourceMetadataFactory;
    private $enabled;
    private $clientEnabled;
    private $clientItemsPerPage;
    private $itemsPerPage;
    private $pageParameterName;
    private $enabledParameterName;
    private $itemsPerPageParameterName;
    private $maximumItemPerPage;
    private $partial;
    private $clientPartial;
    private $partialParameterName;
    private $collectionExtensions;

    /**
     * @param QueryCollectionExtensionInterface[] $collectionExtensions
     */
    public function __construct(ManagerRegistry $managerRegistry, RequestStack $requestStack, ResourceMetadataFactoryInterface $resourceMetadataFactory, bool $enabled = true, bool $clientEnabled = false, bool $clientItemsPerPage = false, int $itemsPerPage = 30, string $pageParameterName = 'page', string $enabledParameterName = 'pagination', string $itemsPerPageParameterName = 'itemsPerPage', int $maximumItemPerPage = null, bool $partial = false, bool $clientPartial = false, string $partialParameterName = 'partial', array $collectionExtensions = [])
    {
        $this->managerRegistry = $managerRegistry;
        $this->requestStack = $requestStack;
        $this->resourceMetadataFactory = $resourceMetadataFactory;
        $this->enabled = $enabled;
        $this->clientEnabled = $clientEnabled;
        $this->clientItemsPerPage = $clientItemsPerPage;
        $this->itemsPerPage = $itemsPerPage;
        $this->pageParameterName = $pageParameterName;
        $this->enabledParameterName = $enabledParameterName;
        $this->itemsPerPageParameterName = $itemsPerPageParameterName;
        $this->maximumItemPerPage = $maximumItemPerPage;
        $this->partial = $partial;
        $this->clientPartial = $clientPartial;
        $this->partialParameterName = $partialParameterName;
        $this->collectionExtensions = $collectionExtensions;
    }

    public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
    {
        return DogBreed::class === $resourceClass
            || NutritionDatabase::class === $resourceClass
            || NutritionDatabaseMetadata::class === $resourceClass
            || NutritionDatabaseMetadataClassification::class === $resourceClass
            || NutritionDatabaseMetadataDimension::class === $resourceClass;
    }

    /**
     * Retrieves a collection.
     *
     * @return array|AbstractPaginator|Traversable
     *
     * @throws ResourceClassNotSupportedException
     * @throws ResourceClassNotFoundException
     */
    public function getCollection(string $resourceClass, string $operationName = null, array $context = [])
    {
        /** @var ObjectManager $manager */
        $manager = $this->managerRegistry->getManagerForClass($resourceClass);
        if (null === $manager) {
            throw new ResourceClassNotSupportedException();
        }

        $repository = $manager->getRepository($resourceClass);
        if (!method_exists($repository, 'createQueryBuilder')) {
            throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');
        }

        $queryBuilder = $repository->createQueryBuilder('o');
        $queryNameGenerator = new QueryNameGenerator();
        foreach ($this->collectionExtensions as $extension) {
            /* @var PaginationExtension $extension */
            $extension->applyToCollection($queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $context);

            if ($extension instanceof PaginationExtension && $extension->supportsResult($resourceClass, $operationName, $context)) {
                /** @var QueryBuilder $queryBuilder */
                $query = $queryBuilder->getQuery()->useQueryCache(true)->enableResultCache();
                $query = $this->addTranslatableQueryHints($query);

                // Do the pagination again unless we can find a good solution here to reuse the code :|
                $doctrineOrmPaginator = new DoctrineOrmPaginator($query, $this->useFetchJoinCollection($queryBuilder));
                $doctrineOrmPaginator->setUseOutputWalkers($this->useOutputWalkers($queryBuilder));
                $resourceMetadata = null === $resourceClass ? null : $this->resourceMetadataFactory->create($resourceClass);
                if ($this->isPartialPaginationEnabled($this->requestStack->getCurrentRequest(), $resourceMetadata, $operationName)) {
                    return new class($doctrineOrmPaginator) extends AbstractPaginator {
                    };
                }

                return new Paginator($doctrineOrmPaginator);
            }
        }

        /** @var QueryBuilder $queryBuilder */
        $query = $queryBuilder->getQuery()->useQueryCache(true)->enableResultCache();
        $query = $this->addTranslatableQueryHints($query);

        return $query->getResult();
    }

    /**
     * @param $query
     *
     * @return mixed
     */
    private function addTranslatableQueryHints(Query $query)
    {
        $query->setHint(
            Query::HINT_CUSTOM_OUTPUT_WALKER,
            'Gedmo\\Translatable\\Query\\TreeWalker\\TranslationWalker'
        );
        // locale
        $query->setHint(
            TranslatableListener::HINT_TRANSLATABLE_LOCALE,
            $this->requestStack->getCurrentRequest()->getLocale() // take locale from session or request etc.
        );
        // fallback
        $query->setHint(
            TranslatableListener::HINT_FALLBACK,
            0 // fallback to default values in case if record is not translated
        );
        $query->setHint(TranslatableListener::HINT_INNER_JOIN, true);
//        $query->setHydrationMode(TranslationWalker::HYDRATE_OBJECT_TRANSLATION);
//        $query->setHint(Query::HINT_REFRESH, true);

        return $query;
    }

    /**
     * Determines whether the Paginator should fetch join collections, if the root entity uses composite identifiers it should not.
     *
     * @see https://github.com/doctrine/doctrine2/issues/2910
     */
    private function useFetchJoinCollection(QueryBuilder $queryBuilder): bool
    {
        return !QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry);
    }

    /**
     * Determines whether output walkers should be used.
     */
    private function useOutputWalkers(QueryBuilder $queryBuilder): bool
    {
        /*
         * "Cannot count query that uses a HAVING clause. Use the output walkers for pagination"
         *
         * @see https://github.com/doctrine/doctrine2/blob/900b55d16afdcdeb5100d435a7166d3a425b9873/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L50
         */
        if (QueryChecker::hasHavingClause($queryBuilder)) {
            return true;
        }

        /*
         * "Paginating an entity with foreign key as identifier only works when using the Output Walkers. Call Paginator#setUseOutputWalkers(true) before iterating the paginator."
         *
         * @see https://github.com/doctrine/doctrine2/blob/900b55d16afdcdeb5100d435a7166d3a425b9873/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L87
         */
        if (QueryChecker::hasRootEntityWithForeignKeyIdentifier($queryBuilder, $this->managerRegistry)) {
            return true;
        }

        /*
         * "Cannot select distinct identifiers from query with LIMIT and ORDER BY on a column from a fetch joined to-many association. Use output walkers."
         *
         * @see https://github.com/doctrine/doctrine2/blob/900b55d16afdcdeb5100d435a7166d3a425b9873/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L149
         */
        if (
            QueryChecker::hasMaxResults($queryBuilder) &&
            QueryChecker::hasOrderByOnToManyJoin($queryBuilder, $this->managerRegistry)
        ) {
            return true;
        }

        /*
         * When using composite identifiers pagination will need Output walkers
         */
        if (QueryChecker::hasRootEntityWithCompositeIdentifier($queryBuilder, $this->managerRegistry)) {
            return true;
        }

        // Disable output walkers by default (performance)
        return false;
    }

    private function isPartialPaginationEnabled(Request $request = null, ResourceMetadata $resourceMetadata = null, string $operationName = null): bool
    {
        $enabled = $this->partial;
        $clientEnabled = $this->clientPartial;

        if ($resourceMetadata) {
            $enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_partial', $enabled, true);

            if ($request) {
                $clientEnabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_partial', $clientEnabled, true);
            }
        }

        if ($clientEnabled && $request) {
            $enabled = filter_var($this->getPaginationParameter($request, $this->partialParameterName, $enabled), FILTER_VALIDATE_BOOLEAN);
        }

        return $enabled;
    }

    private function getPaginationParameter(Request $request, string $parameterName, $default = null)
    {
        if (null !== $paginationAttribute = $request->attributes->get('_api_pagination')) {
            return \array_key_exists($parameterName, $paginationAttribute) ? $paginationAttribute[$parameterName] : $default;
        }

        return $request->query->get($parameterName, $default);
    }
}

Thanks @remoteclient. But sadly it is not working w/ nested properties, i.e. w/ serialization groups.

I managed to make it work by setting fetch: 'EXTRA_LAZY' to the attribute.

Thanks @remoteclient. But sadly it is not working w/ nested properties, i.e. w/ serialization groups.

Indeed, translations don’t work on nested properties.

However, I may have found a solution (maybe wrong, but still):

<?php

namespace App\Extension;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultItemExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Query;
use Symfony\Component\HttpFoundation\RequestStack;
use Gedmo\Translatable\TranslatableListener;
use Gedmo\Translatable\Translatable;

class ResultItemExtension implements QueryResultItemExtensionInterface
{
    private $requestStack;

    public function __construct(RequestStack $requestStack)
    {
        $this->requestStack = $requestStack;
    }

    public function getResult(QueryBuilder $queryBuilder)
    {        
        $query = $queryBuilder->getQuery();

        $query->setHint(
            Query::HINT_CUSTOM_OUTPUT_WALKER,
            'Gedmo\\Translatable\\Query\\TreeWalker\\TranslationWalker'
        );

        // locale
        $query->setHint(
            TranslatableListener::HINT_TRANSLATABLE_LOCALE,
            $this->requestStack->getCurrentRequest()->getLocale() // take locale from session or request etc.
        );
        // fallback
        $query->setHint(
            TranslatableListener::HINT_FALLBACK,
            1 // fallback to default values in case if record is not translated
        );

        return $query->getSingleResult();
    }

    public function supportsResult(string $resourceClass, string $operationName = null): bool
    {
        $reflection = new \ReflectionClass($resourceClass);
        return $reflection->implementsInterface(Translatable::class);
    }

    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?string $operationName = null, array $context = [])
    {
        return;
    }
}

I was implementing the getResult method from QueryResultItemExtensionInterface.

If you want to fetch a collection of entities in a single request, just set a negative priority in service.yaml (yeah, I know it’s discouraged in the official documentation, but I’m the bad guy):

    App\Extension\ResultItemExtension:
        arguments:
            - '@request_stack'
        tags:
            - { name: api_platform.doctrine.orm.query_extension.item, priority: -9 }

Should work. The code was run on symfony 5.4 lts, api platform - v2.6.8

Thanks @remoteclient. But sadly it is not working w/ nested properties, i.e. w/ serialization groups.

Thanks, i have no my own translatable extension for api-platform. 😃

Would be good enough if we had an entry in apiplatform docs. I’d put it under https://api-platform.com/docs/core/extensions

@remoteclient works like a charm!! Thank you very much.

@ACC-Txomin please leave a message if this works for you too. Then I can close this issue.