vendor/gedmo/doctrine-extensions/src/Translatable/Query/TreeWalker/TranslationWalker.php line 137

Open in your IDE?
  1. <?php
  2. /*
  3.  * This file is part of the Doctrine Behavioral Extensions package.
  4.  * (c) Gediminas Morkevicius <gediminas.morkevicius@gmail.com> http://www.gediminasm.org
  5.  * For the full copyright and license information, please view the LICENSE
  6.  * file that was distributed with this source code.
  7.  */
  8. namespace Gedmo\Translatable\Query\TreeWalker;
  9. use Doctrine\DBAL\Connection;
  10. use Doctrine\DBAL\Platforms\AbstractPlatform;
  11. use Doctrine\DBAL\Platforms\MySQLPlatform;
  12. use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
  13. use Doctrine\DBAL\Types\Type;
  14. use Doctrine\ORM\Mapping\ClassMetadata;
  15. use Doctrine\ORM\Query;
  16. use Doctrine\ORM\Query\AST\FromClause;
  17. use Doctrine\ORM\Query\AST\Join;
  18. use Doctrine\ORM\Query\AST\Node;
  19. use Doctrine\ORM\Query\AST\RangeVariableDeclaration;
  20. use Doctrine\ORM\Query\AST\SelectStatement;
  21. use Doctrine\ORM\Query\AST\SubselectFromClause;
  22. use Doctrine\ORM\Query\Exec\SingleSelectExecutor;
  23. use Doctrine\ORM\Query\SqlWalker;
  24. use Gedmo\Exception\RuntimeException;
  25. use Gedmo\Translatable\Hydrator\ORM\ObjectHydrator;
  26. use Gedmo\Translatable\Hydrator\ORM\SimpleObjectHydrator;
  27. use Gedmo\Translatable\Mapping\Event\Adapter\ORM as TranslatableEventAdapter;
  28. use Gedmo\Translatable\TranslatableListener;
  29. /**
  30.  * The translation sql output walker makes it possible
  31.  * to translate all query components during single query.
  32.  * It works with any select query, any hydration method.
  33.  *
  34.  * Behind the scenes, during the object hydration it forces
  35.  * custom hydrator in order to interact with TranslatableListener
  36.  * and skip postLoad event which would cause automatic retranslation
  37.  * of the fields.
  38.  *
  39.  * @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
  40.  *
  41.  * @final since gedmo/doctrine-extensions 3.11
  42.  */
  43. class TranslationWalker extends SqlWalker
  44. {
  45.     /**
  46.      * Name for translation fallback hint
  47.      *
  48.      * @internal
  49.      */
  50.     public const HINT_TRANSLATION_FALLBACKS '__gedmo.translatable.stored.fallbacks';
  51.     /**
  52.      * Customized object hydrator name
  53.      *
  54.      * @internal
  55.      */
  56.     public const HYDRATE_OBJECT_TRANSLATION '__gedmo.translatable.object.hydrator';
  57.     /**
  58.      * Customized object hydrator name
  59.      *
  60.      * @internal
  61.      */
  62.     public const HYDRATE_SIMPLE_OBJECT_TRANSLATION '__gedmo.translatable.simple_object.hydrator';
  63.     /**
  64.      * Stores all component references from select clause
  65.      *
  66.      * @var array<string, array<string, mixed>>
  67.      *
  68.      * @phpstan-var array<string, array{metadata: ClassMetadata}>
  69.      */
  70.     private array $translatedComponents = [];
  71.     /**
  72.      * DBAL database platform
  73.      *
  74.      * @var AbstractPlatform
  75.      */
  76.     private $platform;
  77.     /**
  78.      * DBAL database connection
  79.      *
  80.      * @var Connection
  81.      */
  82.     private $conn;
  83.     /**
  84.      * List of aliases to replace with translation
  85.      * content reference
  86.      *
  87.      * @var array<string, string>
  88.      */
  89.     private array $replacements = [];
  90.     /**
  91.      * List of joins for translated components in query
  92.      *
  93.      * @var array<string, string>
  94.      */
  95.     private array $components = [];
  96.     private TranslatableListener $listener;
  97.     public function __construct($query$parserResult, array $queryComponents)
  98.     {
  99.         parent::__construct($query$parserResult$queryComponents);
  100.         $this->conn $this->getConnection();
  101.         $this->platform $this->getConnection()->getDatabasePlatform();
  102.         $this->listener $this->getTranslatableListener();
  103.         $this->extractTranslatedComponents($queryComponents);
  104.     }
  105.     /**
  106.      * @return Query\Exec\AbstractSqlExecutor
  107.      */
  108.     public function getExecutor($AST)
  109.     {
  110.         // If it's not a Select, the TreeWalker ought to skip it, and just return the parent.
  111.         // @see https://github.com/Atlantic18/DoctrineExtensions/issues/2013
  112.         if (!$AST instanceof SelectStatement) {
  113.             return parent::getExecutor($AST);
  114.         }
  115.         $this->prepareTranslatedComponents();
  116.         return new SingleSelectExecutor($AST$this);
  117.     }
  118.     /**
  119.      * @return string
  120.      */
  121.     public function walkSelectStatement(SelectStatement $AST)
  122.     {
  123.         $result parent::walkSelectStatement($AST);
  124.         if ([] === $this->translatedComponents) {
  125.             return $result;
  126.         }
  127.         $hydrationMode $this->getQuery()->getHydrationMode();
  128.         if (Query::HYDRATE_OBJECT === $hydrationMode) {
  129.             $this->getQuery()->setHydrationMode(self::HYDRATE_OBJECT_TRANSLATION);
  130.             $this->getEntityManager()->getConfiguration()->addCustomHydrationMode(
  131.                 self::HYDRATE_OBJECT_TRANSLATION,
  132.                 ObjectHydrator::class
  133.             );
  134.             $this->getQuery()->setHint(Query::HINT_REFRESHtrue);
  135.         } elseif (Query::HYDRATE_SIMPLEOBJECT === $hydrationMode) {
  136.             $this->getQuery()->setHydrationMode(self::HYDRATE_SIMPLE_OBJECT_TRANSLATION);
  137.             $this->getEntityManager()->getConfiguration()->addCustomHydrationMode(
  138.                 self::HYDRATE_SIMPLE_OBJECT_TRANSLATION,
  139.                 SimpleObjectHydrator::class
  140.             );
  141.             $this->getQuery()->setHint(Query::HINT_REFRESHtrue);
  142.         }
  143.         return $result;
  144.     }
  145.     /**
  146.      * @return string
  147.      */
  148.     public function walkSelectClause($selectClause)
  149.     {
  150.         $result parent::walkSelectClause($selectClause);
  151.         return $this->replace($this->replacements$result);
  152.     }
  153.     /**
  154.      * @return string
  155.      */
  156.     public function walkFromClause($fromClause)
  157.     {
  158.         $result parent::walkFromClause($fromClause);
  159.         $result .= $this->joinTranslations($fromClause);
  160.         return $result;
  161.     }
  162.     /**
  163.      * @return string
  164.      */
  165.     public function walkWhereClause($whereClause)
  166.     {
  167.         $result parent::walkWhereClause($whereClause);
  168.         return $this->replace($this->replacements$result);
  169.     }
  170.     /**
  171.      * @return string
  172.      */
  173.     public function walkHavingClause($havingClause)
  174.     {
  175.         $result parent::walkHavingClause($havingClause);
  176.         return $this->replace($this->replacements$result);
  177.     }
  178.     /**
  179.      * @return string
  180.      */
  181.     public function walkOrderByClause($orderByClause)
  182.     {
  183.         $result parent::walkOrderByClause($orderByClause);
  184.         return $this->replace($this->replacements$result);
  185.     }
  186.     /**
  187.      * @return string
  188.      */
  189.     public function walkSubselect($subselect)
  190.     {
  191.         return parent::walkSubselect($subselect);
  192.     }
  193.     /**
  194.      * @return string
  195.      */
  196.     public function walkSubselectFromClause($subselectFromClause)
  197.     {
  198.         $result parent::walkSubselectFromClause($subselectFromClause);
  199.         $result .= $this->joinTranslations($subselectFromClause);
  200.         return $result;
  201.     }
  202.     /**
  203.      * @return string
  204.      */
  205.     public function walkSimpleSelectClause($simpleSelectClause)
  206.     {
  207.         $result parent::walkSimpleSelectClause($simpleSelectClause);
  208.         return $this->replace($this->replacements$result);
  209.     }
  210.     /**
  211.      * @return string
  212.      */
  213.     public function walkGroupByClause($groupByClause)
  214.     {
  215.         $result parent::walkGroupByClause($groupByClause);
  216.         return $this->replace($this->replacements$result);
  217.     }
  218.     /**
  219.      * Walks from clause, and creates translation joins
  220.      * for the translated components
  221.      *
  222.      * @param FromClause|SubselectFromClause $from
  223.      */
  224.     private function joinTranslations(Node $from): string
  225.     {
  226.         $result '';
  227.         foreach ($from->identificationVariableDeclarations as $decl) {
  228.             if ($decl->rangeVariableDeclaration instanceof RangeVariableDeclaration) {
  229.                 if (isset($this->components[$decl->rangeVariableDeclaration->aliasIdentificationVariable])) {
  230.                     $result .= $this->components[$decl->rangeVariableDeclaration->aliasIdentificationVariable];
  231.                 }
  232.             }
  233.             if (isset($decl->joinVariableDeclarations)) {
  234.                 foreach ($decl->joinVariableDeclarations as $joinDecl) {
  235.                     if ($joinDecl->join instanceof Join) {
  236.                         if (isset($this->components[$joinDecl->join->aliasIdentificationVariable])) {
  237.                             $result .= $this->components[$joinDecl->join->aliasIdentificationVariable];
  238.                         }
  239.                     }
  240.                 }
  241.             } else {
  242.                 // based on new changes
  243.                 foreach ($decl->joins as $join) {
  244.                     if ($join instanceof Join) {
  245.                         if (isset($this->components[$join->joinAssociationDeclaration->aliasIdentificationVariable])) {
  246.                             $result .= $this->components[$join->joinAssociationDeclaration->aliasIdentificationVariable];
  247.                         }
  248.                     }
  249.                 }
  250.             }
  251.         }
  252.         return $result;
  253.     }
  254.     /**
  255.      * Creates a left join list for translations
  256.      * on used query components
  257.      *
  258.      * @todo: make it cleaner
  259.      */
  260.     private function prepareTranslatedComponents(): void
  261.     {
  262.         $q $this->getQuery();
  263.         $locale $q->getHint(TranslatableListener::HINT_TRANSLATABLE_LOCALE);
  264.         if (!$locale) {
  265.             // use from listener
  266.             $locale $this->listener->getListenerLocale();
  267.         }
  268.         $defaultLocale $this->listener->getDefaultLocale();
  269.         if ($locale === $defaultLocale && !$this->listener->getPersistDefaultLocaleTranslation()) {
  270.             // Skip preparation as there's no need to translate anything
  271.             return;
  272.         }
  273.         $em $this->getEntityManager();
  274.         $ea = new TranslatableEventAdapter();
  275.         $ea->setEntityManager($em);
  276.         $quoteStrategy $em->getConfiguration()->getQuoteStrategy();
  277.         $joinStrategy $q->getHint(TranslatableListener::HINT_INNER_JOIN) ? 'INNER' 'LEFT';
  278.         foreach ($this->translatedComponents as $dqlAlias => $comp) {
  279.             /** @var ClassMetadata $meta */
  280.             $meta $comp['metadata'];
  281.             $config $this->listener->getConfiguration($em$meta->getName());
  282.             $transClass $this->listener->getTranslationClass($ea$meta->getName());
  283.             $transMeta $em->getClassMetadata($transClass);
  284.             $transTable $quoteStrategy->getTableName($transMeta$this->platform);
  285.             foreach ($config['fields'] as $field) {
  286.                 $compTblAlias $this->walkIdentificationVariable($dqlAlias$field);
  287.                 $tblAlias $this->getSQLTableAlias('trans'.$compTblAlias.$field);
  288.                 $sql {$joinStrategy} JOIN ".$transTable.' '.$tblAlias;
  289.                 $sql .= ' ON '.$tblAlias.'.'.$quoteStrategy->getColumnName('locale'$transMeta$this->platform)
  290.                     .' = '.$this->conn->quote($locale);
  291.                 $sql .= ' AND '.$tblAlias.'.'.$quoteStrategy->getColumnName('field'$transMeta$this->platform)
  292.                     .' = '.$this->conn->quote($field);
  293.                 $identifier $meta->getSingleIdentifierFieldName();
  294.                 $idColName $quoteStrategy->getColumnName($identifier$meta$this->platform);
  295.                 if ($ea->usesPersonalTranslation($transClass)) {
  296.                     $sql .= ' AND '.$tblAlias.'.'.$transMeta->getSingleAssociationJoinColumnName('object')
  297.                         .' = '.$compTblAlias.'.'.$idColName;
  298.                 } else {
  299.                     $sql .= ' AND '.$tblAlias.'.'.$quoteStrategy->getColumnName('objectClass'$transMeta$this->platform)
  300.                         .' = '.$this->conn->quote($config['useObjectClass']);
  301.                     $mappingFK $transMeta->getFieldMapping('foreignKey');
  302.                     $mappingPK $meta->getFieldMapping($identifier);
  303.                     $fkColName $this->getCastedForeignKey($compTblAlias.'.'.$idColName$mappingFK['type'], $mappingPK['type']);
  304.                     $sql .= ' AND '.$tblAlias.'.'.$quoteStrategy->getColumnName('foreignKey'$transMeta$this->platform)
  305.                         .' = '.$fkColName;
  306.                 }
  307.                 isset($this->components[$dqlAlias]) ? $this->components[$dqlAlias] .= $sql $this->components[$dqlAlias] = $sql;
  308.                 $originalField $compTblAlias.'.'.$quoteStrategy->getColumnName($field$meta$this->platform);
  309.                 $substituteField $tblAlias.'.'.$quoteStrategy->getColumnName('content'$transMeta$this->platform);
  310.                 // Treat translation as original field type
  311.                 $fieldMapping $meta->getFieldMapping($field);
  312.                 if ((($this->platform instanceof MySQLPlatform)
  313.                     && in_array($fieldMapping['type'], ['decimal'], true))
  314.                     || (!($this->platform instanceof MySQLPlatform)
  315.                     && !in_array($fieldMapping['type'], ['datetime''datetimetz''date''time'], true))) {
  316.                     $type Type::getType($fieldMapping['type']);
  317.                     $substituteField 'CAST('.$substituteField.' AS '.$type->getSQLDeclaration($fieldMapping$this->platform).')';
  318.                 }
  319.                 // Fallback to original if was asked for
  320.                 if (($this->needsFallback() && (!isset($config['fallback'][$field]) || $config['fallback'][$field]))
  321.                     || (!$this->needsFallback() && isset($config['fallback'][$field]) && $config['fallback'][$field])
  322.                 ) {
  323.                     $substituteField 'COALESCE('.$substituteField.', '.$originalField.')';
  324.                 }
  325.                 $this->replacements[$originalField] = $substituteField;
  326.             }
  327.         }
  328.     }
  329.     /**
  330.      * Checks if translation fallbacks are needed
  331.      */
  332.     private function needsFallback(): bool
  333.     {
  334.         $q $this->getQuery();
  335.         $fallback $q->getHint(TranslatableListener::HINT_FALLBACK);
  336.         if (false === $fallback) {
  337.             // non overrided
  338.             $fallback $this->listener->getTranslationFallback();
  339.         }
  340.         // applies fallbacks to scalar hydration as well
  341.         return (bool) $fallback;
  342.     }
  343.     /**
  344.      * Search for translated components in the select clause
  345.      *
  346.      * @param array<string, array<string, ClassMetadata>> $queryComponents
  347.      *
  348.      * @phpstan-param array<string, array{metadata: ClassMetadata}> $queryComponents
  349.      */
  350.     private function extractTranslatedComponents(array $queryComponents): void
  351.     {
  352.         $em $this->getEntityManager();
  353.         foreach ($queryComponents as $alias => $comp) {
  354.             if (!isset($comp['metadata'])) {
  355.                 continue;
  356.             }
  357.             $meta $comp['metadata'];
  358.             $config $this->listener->getConfiguration($em$meta->getName());
  359.             if ($config && isset($config['fields'])) {
  360.                 $this->translatedComponents[$alias] = $comp;
  361.             }
  362.         }
  363.     }
  364.     /**
  365.      * Get the currently used TranslatableListener
  366.      *
  367.      * @throws RuntimeException if listener is not found
  368.      */
  369.     private function getTranslatableListener(): TranslatableListener
  370.     {
  371.         $em $this->getEntityManager();
  372.         foreach ($em->getEventManager()->getAllListeners() as $listeners) {
  373.             foreach ($listeners as $listener) {
  374.                 if ($listener instanceof TranslatableListener) {
  375.                     return $listener;
  376.                 }
  377.             }
  378.         }
  379.         throw new RuntimeException('The translation listener could not be found');
  380.     }
  381.     /**
  382.      * Replaces given sql $str with required
  383.      * results
  384.      *
  385.      * @param array<string, string> $repl
  386.      */
  387.     private function replace(array $replstring $str): string
  388.     {
  389.         foreach ($repl as $target => $result) {
  390.             $str preg_replace_callback('/(\s|\()('.$target.')(,?)(\s|\)|$)/smi', static fn (array $m): string => $m[1].$result.$m[3].$m[4], $str);
  391.         }
  392.         return $str;
  393.     }
  394.     /**
  395.      * Casts a foreign key if needed
  396.      *
  397.      * @NOTE: personal translations manages that for themselves.
  398.      *
  399.      * @param string $component a column with an alias to cast
  400.      * @param string $typeFK    translation table foreign key type
  401.      * @param string $typePK    primary key type which references translation table
  402.      *
  403.      * @return string modified $component if needed
  404.      */
  405.     private function getCastedForeignKey(string $componentstring $typeFKstring $typePK): string
  406.     {
  407.         // the keys are of same type
  408.         if ($typeFK === $typePK) {
  409.             return $component;
  410.         }
  411.         // try to look at postgres casting
  412.         if ($this->platform instanceof PostgreSQLPlatform) {
  413.             switch ($typeFK) {
  414.                 case 'string':
  415.                 case 'guid':
  416.                     // need to cast to VARCHAR
  417.                     $component .= '::VARCHAR';
  418.                     break;
  419.             }
  420.         }
  421.         // @TODO may add the same thing for MySQL for performance to match index
  422.         return $component;
  423.     }
  424. }