TranslateBehavior.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. <?php
  2. /**
  3. * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
  4. * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  5. *
  6. * Licensed under The MIT License
  7. * For full copyright and license information, please see the LICENSE.txt
  8. * Redistributions of files must retain the above copyright notice.
  9. *
  10. * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  11. * @link http://cakephp.org CakePHP(tm) Project
  12. * @since 3.0.0
  13. * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
  14. */
  15. namespace Cake\Model\Behavior;
  16. use ArrayObject;
  17. use Cake\Collection\Collection;
  18. use Cake\Event\Event;
  19. use Cake\ORM\Behavior;
  20. use Cake\ORM\Entity;
  21. use Cake\ORM\Table;
  22. use Cake\ORM\TableRegistry;
  23. /**
  24. * This behavior provides a way to translate dynamic data by keeping translations
  25. * in a separate table linked to the original record from another one. Translated
  26. * fields can be configured to override those in the main table when fetched or
  27. * put aside into another property for the same entity.
  28. *
  29. * If you wish to override fields, you need to call the `locale` method in this
  30. * behavior for setting the language you want to fetch from the translations table.
  31. *
  32. * If you want to bring all or certain languages for each of the fetched records,
  33. * you can use the custom `translations` finders that is exposed to the table.
  34. */
  35. class TranslateBehavior extends Behavior {
  36. /**
  37. * Table instance
  38. *
  39. * @var \Cake\ORM\Table
  40. */
  41. protected $_table;
  42. /**
  43. * The locale name that will be used to override fields in the bound table
  44. * from the translations table
  45. *
  46. * @var string
  47. */
  48. protected $_locale;
  49. /**
  50. * Default config
  51. *
  52. * These are merged with user-provided configuration when the behavior is used.
  53. *
  54. * @var array
  55. */
  56. protected $_defaultConfig = [
  57. 'implementedFinders' => ['translations' => 'findTranslations'],
  58. 'implementedMethods' => ['locale' => 'locale'],
  59. 'fields' => [],
  60. 'translationTable' => 'i18n'
  61. ];
  62. /**
  63. * Constructor
  64. *
  65. * @param Table $table The table this behavior is attached to.
  66. * @param array $config The config for this behavior.
  67. */
  68. public function __construct(Table $table, array $config = []) {
  69. parent::__construct($table, $config);
  70. $this->_table = $table;
  71. $config = $this->config();
  72. $this->setupFieldAssociations($config['fields'], $config['translationTable']);
  73. }
  74. /**
  75. * Creates the associations between the bound table and every field passed to
  76. * this method.
  77. *
  78. * Additionally it creates a `i18n` HasMany association that will be
  79. * used for fetching all translations for each record in the bound table
  80. *
  81. * @param array $fields list of fields to create associations for
  82. * @param string $table the table name to use for storing each field translation
  83. * @return void
  84. */
  85. public function setupFieldAssociations($fields, $table) {
  86. $alias = $this->_table->alias();
  87. foreach ($fields as $field) {
  88. $name = $this->_table->alias() . '_' . $field . '_translation';
  89. $target = TableRegistry::get($name);
  90. $target->table($table);
  91. $this->_table->hasOne($name, [
  92. 'targetTable' => $target,
  93. 'foreignKey' => 'foreign_key',
  94. 'joinType' => 'LEFT',
  95. 'conditions' => [
  96. $name . '.model' => $alias,
  97. $name . '.field' => $field,
  98. ],
  99. 'propertyName' => $field . '_translation'
  100. ]);
  101. }
  102. $this->_table->hasMany($table, [
  103. 'foreignKey' => 'foreign_key',
  104. 'strategy' => 'subquery',
  105. 'conditions' => ["$table.model" => $alias],
  106. 'propertyName' => '_i18n',
  107. 'dependent' => true
  108. ]);
  109. }
  110. /**
  111. * Callback method that listens to the `beforeFind` event in the bound
  112. * table. It modifies the passed query by eager loading the translated fields
  113. * and adding a formatter to copy the values into the main table records.
  114. *
  115. * @param \Cake\Event\Event $event
  116. * @param \Cake\ORM\Query $query
  117. * @return void
  118. */
  119. public function beforeFind(Event $event, $query) {
  120. $locale = $this->locale();
  121. if (empty($locale)) {
  122. return;
  123. }
  124. $conditions = function($q) use ($locale) {
  125. return $q
  126. ->select(['id', 'content'])
  127. ->where([$q->repository()->alias() . '.locale' => $locale]);
  128. };
  129. $contain = [];
  130. $fields = $this->config('fields');
  131. $alias = $this->_table->alias();
  132. foreach ($fields as $field) {
  133. $contain[$alias . '_' . $field . '_translation'] = $conditions;
  134. }
  135. $query->contain($contain);
  136. $query->formatResults(function($results) use ($locale) {
  137. return $this->_rowMapper($results, $locale);
  138. }, $query::PREPEND);
  139. }
  140. /**
  141. * Modifies the entity before it is saved so that translated fields are persisted
  142. * in the database too.
  143. *
  144. * @param \Cake\Event\Event the beforeSave event that was fired
  145. * @param \Cake\ORM\Entity the entity that is going to be saved
  146. * @param \ArrayObject $options the options passed to the save method
  147. * @return void
  148. */
  149. public function beforeSave(Event $event, Entity $entity, ArrayObject $options) {
  150. $locale = $entity->get('_locale') ?: $this->locale();
  151. $table = $this->config('translationTable');
  152. $newOptions = [$table => ['validate' => false]];
  153. $options['associated'] = $newOptions + $options['associated'];
  154. $this->_bundleTranslatedFields($entity);
  155. if (!$locale) {
  156. return;
  157. }
  158. $values = $entity->extract($this->config('fields'), true);
  159. $fields = array_keys($values);
  160. $primaryKey = (array)$this->_table->primaryKey();
  161. $key = $entity->get(current($primaryKey));
  162. $preexistent = TableRegistry::get($table)->find()
  163. ->select(['id', 'field'])
  164. ->where(['field IN' => $fields, 'locale' => $locale, 'foreign_key' => $key])
  165. ->bufferResults(false)
  166. ->indexBy('field');
  167. $modified = [];
  168. foreach ($preexistent as $field => $translation) {
  169. $translation->set('content', $values[$field]);
  170. $modified[$field] = $translation;
  171. }
  172. $new = array_diff_key($values, $modified);
  173. $model = $this->_table->alias();
  174. foreach ($new as $field => $content) {
  175. $new[$field] = new Entity(compact('locale', 'field', 'content', 'model'), [
  176. 'useSetters' => false,
  177. 'markNew' => true
  178. ]);
  179. }
  180. $entity->set('_i18n', array_values($modified + $new));
  181. $entity->set('_locale', $locale, ['setter' => false]);
  182. $entity->dirty('_locale', false);
  183. foreach ($fields as $field) {
  184. $entity->dirty($field, false);
  185. }
  186. }
  187. /**
  188. * Unsets the temporary `_i18n` property after the entity has been saved
  189. *
  190. * @param \Cake\Event\Event the beforeSave event that was fired
  191. * @param \Cake\ORM\Entity the entity that is going to be saved
  192. * @return void
  193. */
  194. public function afterSave(Event $event, Entity $entity) {
  195. $entity->unsetProperty('_i18n');
  196. }
  197. /**
  198. * Sets all future finds for the bound table to also fetch translated fields for
  199. * the passed locale. If no value is passed, it returns the currently configured
  200. * locale
  201. *
  202. * @param string $locale The locale to use for fetching translated records
  203. * @return string
  204. */
  205. public function locale($locale = null) {
  206. if ($locale === null) {
  207. return $this->_locale;
  208. }
  209. return $this->_locale = (string)$locale;
  210. }
  211. /**
  212. * Custom finder method used to retrieve all translations for the found records.
  213. * Fetched translations can be filtered by locale by passing the `locales` key
  214. * in the options array.
  215. *
  216. * Translated values will be found for each entity under the property `_translations`,
  217. * containing an array indexed by locale name.
  218. *
  219. * ### Example:
  220. *
  221. * {{{
  222. * $article = $articles->find('translations', ['locales' => ['eng', 'deu'])->first();
  223. * $englishTranslatedFields = $article->get('_translations')['eng'];
  224. * }}}
  225. *
  226. * If the `locales` array is not passed, it will bring all translations found
  227. * for each record.
  228. *
  229. * @param \Cake\ORM\Query $query the original query to modify
  230. * @param array $options
  231. * @return \Cake\ORM\Query
  232. */
  233. public function findTranslations($query, $options) {
  234. $locales = isset($options['locales']) ? $options['locales'] : [];
  235. $table = $this->config('translationTable');
  236. return $query
  237. ->contain([$table => function($q) use ($locales, $table) {
  238. if ($locales) {
  239. $q->where(["$table.locale IN" => $locales]);
  240. }
  241. return $q;
  242. }])
  243. ->formatResults([$this, 'groupTranslations'], $query::PREPEND);
  244. }
  245. /**
  246. * Modifies the results from a table find in order to merge the translated fields
  247. * into each entity for a given locale.
  248. *
  249. * @param \Cake\DataSource\ResultSetDecorator $results
  250. * @param string $locale
  251. * @return \Cake\Collection\Collection
  252. */
  253. protected function _rowMapper($results, $locale) {
  254. return $results->map(function($row) use ($locale) {
  255. $options = ['setter' => false, 'guard' => false];
  256. foreach ($this->config('fields') as $field) {
  257. $name = $field . '_translation';
  258. $translation = $row->get($name);
  259. if (!$translation) {
  260. continue;
  261. }
  262. $content = $translation->get('content');
  263. if ($content !== null) {
  264. $row->set($field, $content, $options);
  265. }
  266. unset($row[$name]);
  267. }
  268. $row->set('_locale', $locale, $options);
  269. $row->clean();
  270. return $row;
  271. });
  272. }
  273. /**
  274. * Modifies the results from a table find in order to merge full translation records
  275. * into each entity under the `_translations` key
  276. *
  277. * @param \Cake\Datasource\ResultSetDecorator $results
  278. * @return \Cake\Collection\Collection
  279. */
  280. public function groupTranslations($results) {
  281. return $results->map(function($row) {
  282. $translations = (array)$row->get('_i18n');
  283. $grouped = new Collection($translations);
  284. $result = [];
  285. foreach ($grouped->combine('field', 'content', 'locale') as $locale => $keys) {
  286. $translation = new Entity($keys + ['locale' => $locale], [
  287. 'markNew' => false,
  288. 'useSetters' => false,
  289. 'markClean' => true
  290. ]);
  291. $result[$locale] = $translation;
  292. }
  293. $options = ['setter' => false, 'guard' => false];
  294. $row->set('_translations', $result, $options);
  295. unset($row['_i18n']);
  296. $row->clean();
  297. return $row;
  298. });
  299. }
  300. /**
  301. * Helper method used to generated multiple translated field entities
  302. * out of the data found in the `_translations` property in the passed
  303. * entity. The result will be put into its `_i18n` property
  304. *
  305. * @param \Cake\ORM\Entity $entity
  306. * @return void
  307. */
  308. protected function _bundleTranslatedFields($entity) {
  309. $translations = (array)$entity->get('_translations');
  310. if (empty($translations) && !$entity->dirty('_translations')) {
  311. return;
  312. }
  313. $fields = $this->config('fields');
  314. $primaryKey = (array)$this->_table->primaryKey();
  315. $key = $entity->get(current($primaryKey));
  316. $find = [];
  317. foreach ($translations as $lang => $translation) {
  318. foreach ($fields as $field) {
  319. if (!$translation->dirty($field)) {
  320. continue;
  321. }
  322. $find[] = ['locale' => $lang, 'field' => $field, 'foreign_key' => $key];
  323. $contents[] = new Entity(['content' => $translation->get($field)], [
  324. 'useSetters' => false
  325. ]);
  326. }
  327. }
  328. if (empty($find)) {
  329. return;
  330. }
  331. $results = $this->_findExistingTranslations($find);
  332. $alias = $this->_table->alias();
  333. foreach ($find as $i => $translation) {
  334. if (!empty($results[$i])) {
  335. $contents[$i]->set('id', $results[$i], ['setter' => false]);
  336. $contents[$i]->isNew(false);
  337. } else {
  338. $translation['model'] = $alias;
  339. $contents[$i]->set($translation, ['setter' => false, 'guard' => false]);
  340. $contents[$i]->isNew(true);
  341. }
  342. }
  343. $entity->set('_i18n', $contents);
  344. }
  345. /**
  346. * Returns the ids found for each of the condition arrays passed for the translations
  347. * table. Each records is indexed by the corresponding position to the conditions array
  348. *
  349. * @param array $ruleSet an array of arary of conditions to be used for finding each
  350. * @return array
  351. */
  352. protected function _findExistingTranslations($ruleSet) {
  353. $association = $this->_table->association($this->config('translationTable'));
  354. $query = $association->find()
  355. ->select(['id', 'num' => 0])
  356. ->where(current($ruleSet))
  357. ->hydrate(false)
  358. ->bufferResults(false);
  359. unset($ruleSet[0]);
  360. foreach ($ruleSet as $i => $conditions) {
  361. $q = $association->find()
  362. ->select(['id', 'num' => $i])
  363. ->where($conditions);
  364. $query->unionAll($q);
  365. }
  366. return $query->combine('num', 'id')->toArray();
  367. }
  368. }