TranslationWalker.php 14 KB

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