Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
for virtual setters; creates and attaches the missing translation for the
exact locale, without locale fallback, regardless of
`auto_create_translations`
- Translations of translatable resources are now fetch-joined (experimental
`TranslationsEagerLoadingExtension`), so listing N resources issues one query
instead of one translation query per entity per locale; disable with the
`eager_load_translations` option (#10)

### Changed
- Modern bundle layout: the bundle class extends `AbstractBundle` and services
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ api_platform_translation:
# 3.0; leaving the option unset is deprecated since 2.1.
auto_create_translations: true

# Whether queries for translatable resources fetch-join the translations,
# so listing N resources issues one query instead of one per entity per
# locale. Set false to restore lazy loading.
eager_load_translations: true

# Ordered sources the request locale is resolved from; the first source
# producing a locale wins. Remove a source to disable it.
locale_resolution:
Expand Down Expand Up @@ -359,10 +364,6 @@ Limitations:
virtual getters, so built-in API Platform filters (`SearchFilter`,
`OrderFilter`, ...) cannot target them on the resource. Filtering on
translation fields requires a custom filter joining the translation entity.
- **Collections issue one translation query per item.** The `translations`
association is `EXTRA_LAZY` and resolved per entity, so listing N resources
triggers N additional queries for their translations; there is no built-in
eager loading yet.

## Contribution

Expand Down
11 changes: 10 additions & 1 deletion UPGRADE-2.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,14 @@ These only affect you if you referenced bundle files by path:

The bundle now has a configuration tree under `api_platform_translation`
(`enabled_locales`, `fallback_locale`, `auto_create_translations`,
`locale_resolution`). All defaults preserve 2.0 behavior; see the README
`eager_load_translations`, `locale_resolution`). All defaults except
`eager_load_translations` preserve 2.0 behavior; see the README
"Configuration" section.

## Eager loading of translations

Queries for translatable resources now fetch-join the translations
(experimental), so listing N resources issues one query instead of one
translation query per entity per locale. Responses are unchanged; only the
generated SQL differs. Set `eager_load_translations: false` to restore the
2.0 lazy behavior.
15 changes: 15 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use Locastic\ApiPlatformTranslationBundle\Doctrine\Orm\Extension\TranslationsEagerLoadingExtension;
use Locastic\ApiPlatformTranslationBundle\EventListener\AssignLocaleListener;
use Locastic\ApiPlatformTranslationBundle\Serializer\TranslatableItemDenormalizer;
use Locastic\ApiPlatformTranslationBundle\Translation\Translator;
Expand Down Expand Up @@ -37,6 +39,19 @@

$services->alias(TranslatableItemDenormalizer::class, 'locastic_api_platform_translation.serializer.translatable_denormalizer');

// Doctrine ORM: eager loading of translations (the interface lives in
// api-platform/doctrine-orm, which api-platform/symfony does not require)
if (interface_exists(QueryCollectionExtensionInterface::class)) {
$services->set('locastic_api_platform_translation.doctrine.orm.query_extension.translations_eager_loading', TranslationsEagerLoadingExtension::class)
->args([
param('locastic_api_platform_translation.eager_load_translations'),
])
->tag('api_platform.doctrine.orm.query_extension.collection', ['priority' => -18])
->tag('api_platform.doctrine.orm.query_extension.item', ['priority' => -8]);

$services->alias(TranslationsEagerLoadingExtension::class, 'locastic_api_platform_translation.doctrine.orm.query_extension.translations_eager_loading');
}

// Filters
$services->set('locastic_api_platform_translation.filter.translation_groups')
->parent('api_platform.serializer.group_filter')
Expand Down
7 changes: 6 additions & 1 deletion src/ApiPlatformTranslationBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ public function configure(DefinitionConfigurator $definition): void
->info('Whether reading a translation that does not exist creates an empty one and attaches it to the entity (with cascade persist, a read can then insert empty rows). Set false so reads never write: missing translations just show empty fields. Defaults to true; the default flips to false in 3.0.')
->defaultNull()
->end()
->booleanNode('eager_load_translations')
->info('Whether queries for translatable resources fetch-join the translations, so a collection issues one query instead of one per entity per locale. Set false to restore lazy loading.')
->defaultTrue()
->end()
->arrayNode('locale_resolution')
->info('Ordered list of sources the request locale is resolved from; the first source producing a locale wins. Remove a source to disable it.')
->performNoDeepMerging()
Expand All @@ -47,7 +51,7 @@ public function configure(DefinitionConfigurator $definition): void
}

/**
* @param array{enabled_locales: list<string>, fallback_locale: ?string, auto_create_translations: ?bool, locale_resolution: list<string>} $config
* @param array{enabled_locales: list<string>, fallback_locale: ?string, auto_create_translations: ?bool, eager_load_translations: bool, locale_resolution: list<string>} $config
*/
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{
Expand All @@ -63,6 +67,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C
->set('locastic_api_platform_translation.enabled_locales', $config['enabled_locales'] ?: '%kernel.enabled_locales%')
->set('locastic_api_platform_translation.fallback_locale', $config['fallback_locale'] ?? '%kernel.default_locale%')
->set('locastic_api_platform_translation.auto_create_translations', $config['auto_create_translations'] ?? true)
->set('locastic_api_platform_translation.eager_load_translations', $config['eager_load_translations'])
->set('locastic_api_platform_translation.locale_resolution', $config['locale_resolution']);

$container->import('../config/services.php');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public function load(array $configs, ContainerBuilder $container): void
$container->setParameter('locastic_api_platform_translation.enabled_locales', '%kernel.enabled_locales%');
$container->setParameter('locastic_api_platform_translation.fallback_locale', '%kernel.default_locale%');
$container->setParameter('locastic_api_platform_translation.auto_create_translations', true);
$container->setParameter('locastic_api_platform_translation.eager_load_translations', true);
$container->setParameter('locastic_api_platform_translation.locale_resolution', ['query_param', 'accept_language']);

$loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'config'));
Expand Down
90 changes: 90 additions & 0 deletions src/Doctrine/Orm/Extension/TranslationsEagerLoadingExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace Locastic\ApiPlatformTranslationBundle\Doctrine\Orm\Extension;

use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Locastic\ApiPlatformTranslationBundle\Model\TranslatableInterface;

/**
* Fetch-joins the translations of translatable resources, so reading translated
* fields issues one query per collection instead of one per entity per locale.
*
* All locales are joined, not only the current one: a locale-constrained fetch
* join would mark the collection initialized with a subset, breaking
* fallback-locale reads and the `translations` serialization group.
*
* @experimental
*/
final class TranslationsEagerLoadingExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(private readonly bool $enabled = true)
{
}

public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$this->joinTranslations($queryBuilder, $queryNameGenerator, $resourceClass);
}

/**
* @param array<string, mixed> $identifiers
*/
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
{
$this->joinTranslations($queryBuilder, $queryNameGenerator, $resourceClass);
}

private function joinTranslations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass): void
{
if (!$this->enabled || !is_a($resourceClass, TranslatableInterface::class, true)) {
return;
}

$rootAlias = $queryBuilder->getRootAliases()[0] ?? null;
if (null === $rootAlias || $this->hasFetchJoin($queryBuilder, $rootAlias)) {
return;
}

$joinAlias = $queryNameGenerator->generateJoinAlias('translations');
$queryBuilder
->leftJoin(\sprintf('%s.translations', $rootAlias), $joinAlias)
->addSelect($joinAlias);
}

/**
* Only an unconstrained, selected join counts: a filter may join translations
* WITH a locale condition, and selecting that alias would hydrate a partial
* collection, so such joins are ignored and a dedicated one is added.
*/
private function hasFetchJoin(QueryBuilder $queryBuilder, string $rootAlias): bool
{
$selected = [];
foreach ($queryBuilder->getDQLPart('select') as $select) {
foreach ($select->getParts() as $part) {
if (\is_string($part)) {
$selected[] = $part;
}
}
}

/** @var array<string, Join[]> $joinParts */
$joinParts = $queryBuilder->getDQLPart('join');
foreach ($joinParts[$rootAlias] ?? [] as $join) {
if (\sprintf('%s.translations', $rootAlias) === $join->getJoin()
&& null === $join->getCondition()
&& \in_array($join->getAlias(), $selected, true)
) {
return true;
}
}

return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public function testDeprecatedExtensionStillRegistersServices(): void
$this->assertTrue($container->hasDefinition('locastic_api_platform_translation.filter.translation_groups'));
$this->assertSame('%kernel.enabled_locales%', $container->getParameter('locastic_api_platform_translation.enabled_locales'));
$this->assertTrue($container->getParameter('locastic_api_platform_translation.auto_create_translations'));
$this->assertTrue($container->getParameter('locastic_api_platform_translation.eager_load_translations'));
$this->assertSame('api_platform_translation', $extension->getAlias());
}
}
177 changes: 177 additions & 0 deletions tests/Doctrine/Orm/Extension/TranslationsEagerLoadingExtensionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<?php

declare(strict_types=1);

namespace Locastic\ApiPlatformTranslationBundle\Tests\Doctrine\Orm\Extension;

use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator;
use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMSetup;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\SchemaTool;
use Locastic\ApiPlatformTranslationBundle\Doctrine\Orm\Extension\TranslationsEagerLoadingExtension;
use Locastic\ApiPlatformTranslationBundle\Tests\Fixtures\DummyNotTranslatable;
use Locastic\ApiPlatformTranslationBundle\Tests\Fixtures\Orm\IntegrationArticle;
use Locastic\ApiPlatformTranslationBundle\Tests\Fixtures\Orm\IntegrationArticleTranslation;
use PHPUnit\Framework\TestCase;

/**
* Exercises the extension against a real Doctrine EntityManager (in-memory
* sqlite): the DQL it produces, and that hydrating the fetch join leaves the
* translations collections initialized, which is what makes later
* getTranslation() calls filter in memory instead of querying per locale.
*/
final class TranslationsEagerLoadingExtensionTest extends TestCase
{
private EntityManagerInterface $em;

protected function setUp(): void
{
// Use the long-standing `...Configuration` name: the shorter `...Config`
// alias was only added mid-3.x, and the ORM floor (installed on the
// lowest-dependencies CI leg) does not have it.
$config = ORMSetup::createAttributeMetadataConfiguration([__DIR__.'/../../../Fixtures/Orm'], true);
// On PHP 8.4+ (where Symfony 8 / var-exporter 8 dropped the lazy-ghost trait)
// ORM needs native lazy objects; on older PHP the lazy-ghost trait handles it
// but still requires a configured proxy directory, which this config helper
// does not set on its own.
if (\PHP_VERSION_ID >= 80400) {
$config->enableNativeLazyObjects(true);
} else {
$config->setProxyDir(sys_get_temp_dir());
$config->setProxyNamespace('LocasticApiPlatformTranslationTestProxies');
}
$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $config);
$this->em = new EntityManager($connection, $config);

$schemaTool = new SchemaTool($this->em);
$schemaTool->createSchema($this->em->getMetadataFactory()->getAllMetadata());
}

public function testCollectionQueryFetchJoinsTranslations(): void
{
$queryBuilder = $this->createQueryBuilder();

(new TranslationsEagerLoadingExtension())
->applyToCollection($queryBuilder, new QueryNameGenerator(), IntegrationArticle::class);

self::assertSame(
\sprintf('SELECT o, translations_a1 FROM %s o LEFT JOIN o.translations translations_a1', IntegrationArticle::class),
$queryBuilder->getDQL(),
);
}

public function testItemQueryFetchJoinsTranslations(): void
{
$queryBuilder = $this->createQueryBuilder();

(new TranslationsEagerLoadingExtension())
->applyToItem($queryBuilder, new QueryNameGenerator(), IntegrationArticle::class, ['id' => 1]);

self::assertStringContainsString('LEFT JOIN o.translations translations_a1', $queryBuilder->getDQL());
}

public function testHydratedCollectionsAreInitialized(): void
{
$this->seedArticle(['en' => 'EN one', 'de' => 'DE one']);
$this->seedArticle(['en' => 'EN two', 'de' => 'DE two']);

$queryBuilder = $this->createQueryBuilder();
(new TranslationsEagerLoadingExtension())
->applyToCollection($queryBuilder, new QueryNameGenerator(), IntegrationArticle::class);

/** @var list<IntegrationArticle> $articles */
$articles = $queryBuilder->getQuery()->getResult();

self::assertCount(2, $articles);
foreach ($articles as $article) {
$translations = $article->getTranslations();
self::assertInstanceOf(PersistentCollection::class, $translations);
// An initialized PersistentCollection resolves matching() in memory,
// so every later getTranslation() is query-free.
self::assertTrue($translations->isInitialized());
self::assertCount(2, $translations);
}
self::assertSame('DE one', $articles[0]->getTranslation('de')->getTitle());
}

public function testSkipsNonTranslatableResources(): void
{
$queryBuilder = $this->createQueryBuilder();
$dql = $queryBuilder->getDQL();

(new TranslationsEagerLoadingExtension())
->applyToCollection($queryBuilder, new QueryNameGenerator(), DummyNotTranslatable::class);

self::assertSame($dql, $queryBuilder->getDQL());
}

public function testDoesNothingWhenDisabled(): void
{
$queryBuilder = $this->createQueryBuilder();
$dql = $queryBuilder->getDQL();

(new TranslationsEagerLoadingExtension(false))
->applyToCollection($queryBuilder, new QueryNameGenerator(), IntegrationArticle::class);

self::assertSame($dql, $queryBuilder->getDQL());
}

public function testDoesNotDuplicateAnExistingFetchJoin(): void
{
$queryBuilder = $this->createQueryBuilder();
$queryNameGenerator = new QueryNameGenerator();
$extension = new TranslationsEagerLoadingExtension();

$extension->applyToCollection($queryBuilder, $queryNameGenerator, IntegrationArticle::class);
$dql = $queryBuilder->getDQL();
$extension->applyToCollection($queryBuilder, $queryNameGenerator, IntegrationArticle::class);

self::assertSame($dql, $queryBuilder->getDQL());
}

public function testAddsItsOwnJoinNextToALocaleConstrainedFilterJoin(): void
{
$queryBuilder = $this->createQueryBuilder()
->leftJoin('o.translations', 't', Join::WITH, "t.locale = 'en'");

(new TranslationsEagerLoadingExtension())
->applyToCollection($queryBuilder, new QueryNameGenerator(), IntegrationArticle::class);

// Selecting the constrained alias would hydrate a partial collection, so
// the filter join must be left alone and an unconstrained one added.
self::assertSame(
\sprintf("SELECT o, translations_a1 FROM %s o LEFT JOIN o.translations t WITH t.locale = 'en' LEFT JOIN o.translations translations_a1", IntegrationArticle::class),
$queryBuilder->getDQL(),
);
}

private function createQueryBuilder(): QueryBuilder
{
return $this->em->createQueryBuilder()
->select('o')
->from(IntegrationArticle::class, 'o');
}

/**
* @param array<string, string> $localeToTitle
*/
private function seedArticle(array $localeToTitle): void
{
$article = new IntegrationArticle();
foreach ($localeToTitle as $locale => $title) {
$translation = new IntegrationArticleTranslation();
$translation->setLocale($locale);
$translation->setTitle($title);
$article->addTranslation($translation);
}

$this->em->persist($article);
$this->em->flush();
$this->em->clear();
}
}
Loading
Loading