TranslateBehavior.php 12 KB

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