TranslationWalker.php 14 KB

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