TranslationWalker.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. <?php
  2. namespace Gedmo\Translatable\Query\TreeWalker;
  3. use Gedmo\Translatable\Mapping\Event\Adapter\ORM as TranslatableEventAdapter;
  4. use Gedmo\Translatable\TranslatableListener;
  5. use Doctrine\ORM\Query;
  6. use Doctrine\ORM\Query\SqlWalker;
  7. use Doctrine\ORM\Query\TreeWalkerAdapter;
  8. use Doctrine\ORM\Query\AST\SelectStatement;
  9. use Doctrine\ORM\Query\Exec\SingleSelectExecutor;
  10. use Doctrine\ORM\Query\AST\RangeVariableDeclaration;
  11. use Doctrine\ORM\Query\AST\Join;
  12. /**
  13. * The translation sql output walker makes it possible
  14. * to translate all query components during single query.
  15. * It works with any select query, any hydration method.
  16. *
  17. * Behind the scenes, during the object hydration it forces
  18. * custom hydrator in order to interact with TranslatableListener
  19. * and skip postLoad event which would couse automatic retranslation
  20. * of the fields.
  21. *
  22. * @author Gediminas Morkevicius <gediminas.morkevicius@gmail.com>
  23. * @package Gedmo.Translatable.Query.TreeWalker
  24. * @subpackage TranslationWalker
  25. * @link http://www.gediminasm.org
  26. * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
  27. */
  28. class TranslationWalker extends SqlWalker
  29. {
  30. /**
  31. * Name for translation fallback hint
  32. *
  33. * @internal
  34. */
  35. const HINT_TRANSLATION_FALLBACKS = '__gedmo.translatable.stored.fallbacks';
  36. /**
  37. * Customized object hydrator name
  38. *
  39. * @internal
  40. */
  41. const HYDRATE_OBJECT_TRANSLATION = '__gedmo.translatable.object.hydrator';
  42. /**
  43. * Customized object hydrator name
  44. *
  45. * @internal
  46. */
  47. const HYDRATE_SIMPLE_OBJECT_TRANSLATION = '__gedmo.translatable.simple_object.hydrator';
  48. /**
  49. * Stores all component references from select clause
  50. *
  51. * @var array
  52. */
  53. private $translatedComponents = array();
  54. /**
  55. * DBAL database platform
  56. *
  57. * @var Doctrine\DBAL\Platforms\AbstractPlatform
  58. */
  59. private $platform;
  60. /**
  61. * DBAL database connection
  62. *
  63. * @var Doctrine\DBAL\Connection
  64. */
  65. private $conn;
  66. /**
  67. * List of aliases to replace with translation
  68. * content reference
  69. *
  70. * @var array
  71. */
  72. private $replacements = array();
  73. /**
  74. * List of joins for translated components in query
  75. *
  76. * @var array
  77. */
  78. private $components = array();
  79. /**
  80. * {@inheritDoc}
  81. */
  82. public function __construct($query, $parserResult, array $queryComponents)
  83. {
  84. parent::__construct($query, $parserResult, $queryComponents);
  85. $this->conn = $this->getConnection();
  86. $this->platform = $this->getConnection()->getDatabasePlatform();
  87. $this->listener = $this->getTranslatableListener();
  88. $this->extractTranslatedComponents($queryComponents);
  89. }
  90. /**
  91. * {@inheritDoc}
  92. */
  93. public function getExecutor($AST)
  94. {
  95. if (!$AST instanceof SelectStatement) {
  96. throw new \Gedmo\Exception\UnexpectedValueException('Translation walker should be used only on select statement');
  97. }
  98. $this->prepareTranslatedComponents();
  99. return new SingleSelectExecutor($AST, $this);
  100. }
  101. /**
  102. * {@inheritDoc}
  103. */
  104. public function walkSelectStatement(SelectStatement $AST)
  105. {
  106. $result = parent::walkSelectStatement($AST);
  107. if (!count($this->translatedComponents)) {
  108. return $result;
  109. }
  110. $hydrationMode = $this->getQuery()->getHydrationMode();
  111. if ($hydrationMode === Query::HYDRATE_OBJECT) {
  112. $this->getQuery()->setHydrationMode(self::HYDRATE_OBJECT_TRANSLATION);
  113. $this->getEntityManager()->getConfiguration()->addCustomHydrationMode(
  114. self::HYDRATE_OBJECT_TRANSLATION,
  115. 'Gedmo\\Translatable\\Hydrator\\ORM\\ObjectHydrator'
  116. );
  117. $this->getQuery()->setHint(Query::HINT_REFRESH, true);
  118. } elseif ($hydrationMode === Query::HYDRATE_SIMPLEOBJECT) {
  119. $this->getQuery()->setHydrationMode(self::HYDRATE_SIMPLE_OBJECT_TRANSLATION);
  120. $this->getEntityManager()->getConfiguration()->addCustomHydrationMode(
  121. self::HYDRATE_SIMPLE_OBJECT_TRANSLATION,
  122. 'Gedmo\\Translatable\\Hydrator\\ORM\\SimpleObjectHydrator'
  123. );
  124. $this->getQuery()->setHint(Query::HINT_REFRESH, true);
  125. }
  126. return $result;
  127. }
  128. /**
  129. * {@inheritDoc}
  130. */
  131. public function walkSelectClause($selectClause)
  132. {
  133. $result = parent::walkSelectClause($selectClause);
  134. $result = $this->replace($this->replacements, $result);
  135. return $result;
  136. }
  137. /**
  138. * {@inheritDoc}
  139. */
  140. public function walkFromClause($fromClause)
  141. {
  142. $result = parent::walkFromClause($fromClause);
  143. $result .= $this->joinTranslations($fromClause);
  144. return $result;
  145. }
  146. /**
  147. * {@inheritDoc}
  148. */
  149. public function walkWhereClause($whereClause)
  150. {
  151. $result = parent::walkWhereClause($whereClause);
  152. return $this->replace($this->replacements, $result);
  153. }
  154. /**
  155. * {@inheritDoc}
  156. */
  157. public function walkHavingClause($havingClause)
  158. {
  159. $result = parent::walkHavingClause($havingClause);
  160. return $this->replace($this->replacements, $result);
  161. }
  162. /**
  163. * {@inheritDoc}
  164. */
  165. public function walkOrderByClause($orderByClause)
  166. {
  167. $result = parent::walkOrderByClause($orderByClause);
  168. return $this->replace($this->replacements, $result);
  169. }
  170. /**
  171. * {@inheritDoc}
  172. */
  173. public function walkSubselect($subselect)
  174. {
  175. $result = parent::walkSubselect($subselect);
  176. return $result;
  177. }
  178. /**
  179. * {@inheritDoc}
  180. */
  181. public function walkSubselectFromClause($subselectFromClause)
  182. {
  183. $result = parent::walkSubselectFromClause($subselectFromClause);
  184. $result .= $this->joinTranslations($subselectFromClause);
  185. return $result;
  186. }
  187. /**
  188. * {@inheritDoc}
  189. */
  190. public function walkSimpleSelectClause($simpleSelectClause)
  191. {
  192. $result = parent::walkSimpleSelectClause($simpleSelectClause);
  193. return $this->replace($this->replacements, $result);
  194. }
  195. /**
  196. * Walks from clause, and creates translation joins
  197. * for the translated components
  198. *
  199. * @param Doctrine\ORM\Query\AST\FromClause $from
  200. * @return string
  201. */
  202. private function joinTranslations($from)
  203. {
  204. $result = '';
  205. foreach ($from->identificationVariableDeclarations as $decl) {
  206. if ($decl->rangeVariableDeclaration instanceof RangeVariableDeclaration) {
  207. if (isset($this->components[$decl->rangeVariableDeclaration->aliasIdentificationVariable])) {
  208. $result .= $this->components[$decl->rangeVariableDeclaration->aliasIdentificationVariable];
  209. }
  210. }
  211. if (isset($decl->joinVariableDeclarations)) {
  212. foreach ($decl->joinVariableDeclarations as $joinDecl) {
  213. if ($joinDecl->join instanceof Join) {
  214. if (isset($this->components[$joinDecl->join->aliasIdentificationVariable])) {
  215. $result .= $this->components[$joinDecl->join->aliasIdentificationVariable];
  216. }
  217. }
  218. }
  219. } else {
  220. // based on new changes
  221. foreach ($decl->joins as $join) {
  222. if ($join instanceof Join) {
  223. if (isset($this->components[$join->joinAssociationDeclaration->aliasIdentificationVariable])) {
  224. $result .= $this->components[$join->joinAssociationDeclaration->aliasIdentificationVariable];
  225. }
  226. }
  227. }
  228. }
  229. }
  230. return $result;
  231. }
  232. /**
  233. * Creates a left join list for translations
  234. * on used query components
  235. *
  236. * @todo: make it cleaner
  237. * @return string
  238. */
  239. private function prepareTranslatedComponents()
  240. {
  241. $q = $this->getQuery();
  242. $locale = $q->getHint(TranslatableListener::HINT_TRANSLATABLE_LOCALE);
  243. if (!$locale) {
  244. // use from listener
  245. $locale = $this->listener->getListenerLocale();
  246. }
  247. $defaultLocale = $this->listener->getDefaultLocale();
  248. if ($locale === $defaultLocale) {
  249. // Skip preparation as there's no need to translate anything
  250. return;
  251. }
  252. $em = $this->getEntityManager();
  253. $ea = new TranslatableEventAdapter;
  254. $ea->setEntityManager($em);
  255. $joinStrategy = $q->getHint(TranslatableListener::HINT_INNER_JOIN) ? 'INNER' : 'LEFT';
  256. foreach ($this->translatedComponents as $dqlAlias => $comp) {
  257. $meta = $comp['metadata'];
  258. $config = $this->listener->getConfiguration($em, $meta->name);
  259. $transClass = $this->listener->getTranslationClass($ea, $meta->name);
  260. $transMeta = $em->getClassMetadata($transClass);
  261. $transTable = $transMeta->getQuotedTableName($this->platform);
  262. foreach ($config['fields'] as $field) {
  263. $compTableName = $meta->getQuotedTableName($this->platform);
  264. $compTblAlias = $this->getSQLTableAlias($compTableName, $dqlAlias);
  265. $tblAlias = $this->getSQLTableAlias('trans'.$compTblAlias.$field);
  266. $sql = " {$joinStrategy} JOIN ".$transTable.' '.$tblAlias;
  267. $sql .= ' ON '.$tblAlias.'.'.$transMeta->getQuotedColumnName('locale', $this->platform)
  268. .' = '.$this->conn->quote($locale);
  269. $sql .= ' AND '.$tblAlias.'.'.$transMeta->getQuotedColumnName('field', $this->platform)
  270. .' = '.$this->conn->quote($field);
  271. $identifier = $meta->getSingleIdentifierFieldName();
  272. $idColName = $meta->getQuotedColumnName($identifier, $this->platform);
  273. if ($ea->usesPersonalTranslation($transClass)) {
  274. $sql .= ' AND '.$tblAlias.'.'.$transMeta->getSingleAssociationJoinColumnName('object')
  275. .' = '.$compTblAlias.'.'.$idColName;
  276. } else {
  277. $sql .= ' AND '.$tblAlias.'.'.$transMeta->getQuotedColumnName('objectClass', $this->platform)
  278. .' = '.$this->conn->quote($meta->name);
  279. $sql .= ' AND '.$tblAlias.'.'.$transMeta->getQuotedColumnName('foreignKey', $this->platform)
  280. .' = '.$compTblAlias.'.'.$idColName;
  281. }
  282. isset($this->components[$dqlAlias]) ? $this->components[$dqlAlias] .= $sql : $this->components[$dqlAlias] = $sql;
  283. $originalField = $compTblAlias.'.'.$meta->getQuotedColumnName($field, $this->platform);
  284. $substituteField = $tblAlias . '.' . $transMeta->getQuotedColumnName('content', $this->platform);
  285. // If original field is integer - treat translation as integer (for ORDER BY, WHERE, etc)
  286. $fieldMapping = $meta->getFieldMapping($field);
  287. if (in_array($fieldMapping["type"], array("integer", "bigint", "tinyint", "int"))) {
  288. $substituteField = 'CAST(' . $substituteField . ' AS SIGNED)';
  289. }
  290. // Fallback to original if was asked for
  291. if (($this->needsFallback() && (!isset($config['fallback'][$field]) || $config['fallback'][$field]))
  292. || (!$this->needsFallback() && isset($config['fallback'][$field]) && $config['fallback'][$field])
  293. ) {
  294. $substituteField = 'COALESCE('.$substituteField.', '.$originalField.')';
  295. }
  296. $this->replacements[$originalField] = $substituteField;
  297. }
  298. }
  299. }
  300. /**
  301. * Checks if translation fallbacks are needed
  302. *
  303. * @return boolean
  304. */
  305. private function needsFallback()
  306. {
  307. $q = $this->getQuery();
  308. $fallback = $q->getHint(TranslatableListener::HINT_FALLBACK);
  309. if (false === $fallback) {
  310. // non overrided
  311. $fallback = $this->listener->getTranslationFallback();
  312. }
  313. return (bool)$fallback
  314. && $q->getHydrationMode() !== Query::HYDRATE_SCALAR
  315. && $q->getHydrationMode() !== Query::HYDRATE_SINGLE_SCALAR;
  316. }
  317. /**
  318. * Search for translated components in the select clause
  319. *
  320. * @param array $queryComponents
  321. * @return void
  322. */
  323. private function extractTranslatedComponents(array $queryComponents)
  324. {
  325. $em = $this->getEntityManager();
  326. foreach ($queryComponents as $alias => $comp) {
  327. if (!isset($comp['metadata'])) {
  328. continue;
  329. }
  330. $meta = $comp['metadata'];
  331. $config = $this->listener->getConfiguration($em, $meta->name);
  332. if ($config && isset($config['fields'])) {
  333. $this->translatedComponents[$alias] = $comp;
  334. }
  335. }
  336. }
  337. /**
  338. * Get the currently used TranslatableListener
  339. *
  340. * @throws \Gedmo\Exception\RuntimeException - if listener is not found
  341. * @return TranslatableListener
  342. */
  343. private function getTranslatableListener()
  344. {
  345. $translatableListener = null;
  346. $em = $this->getEntityManager();
  347. foreach ($em->getEventManager()->getListeners() as $event => $listeners) {
  348. foreach ($listeners as $hash => $listener) {
  349. if ($listener instanceof TranslatableListener) {
  350. $translatableListener = $listener;
  351. break;
  352. }
  353. }
  354. if ($translatableListener) {
  355. break;
  356. }
  357. }
  358. if (is_null($translatableListener)) {
  359. throw new \Gedmo\Exception\RuntimeException('The translation listener could not be found');
  360. }
  361. return $translatableListener;
  362. }
  363. /**
  364. * Replaces given sql $str with required
  365. * results
  366. *
  367. * @param array $repl
  368. * @param string $str
  369. * @return string
  370. */
  371. private function replace(array $repl, $str)
  372. {
  373. foreach ($repl as $target => $result) {
  374. $str = preg_replace_callback('/(\s|\()('.$target.')(\s|\))/smi', function($m) use ($result) {
  375. return $m[1].$result.$m[3];
  376. }, $str);
  377. }
  378. return $str;
  379. }
  380. }