PageRenderTime 56ms CodeModel.GetById 29ms RepoModel.GetById 1ms app.codeStats 0ms

/src/ORM/Behavior/TranslateBehavior.php

https://github.com/binondord/cakephp
PHP | 544 lines | 310 code | 63 blank | 171 comment | 22 complexity | bd86fac7298e5f2255004bdf8b27d3b6 MD5 | raw file
  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\ORM\Behavior;
  16. use ArrayObject;
  17. use Cake\Collection\Collection;
  18. use Cake\Datasource\EntityInterface;
  19. use Cake\Event\Event;
  20. use Cake\I18n\I18n;
  21. use Cake\ORM\Behavior;
  22. use Cake\ORM\Entity;
  23. use Cake\ORM\Query;
  24. use Cake\ORM\Table;
  25. use Cake\ORM\TableRegistry;
  26. use Cake\Utility\Inflector;
  27. /**
  28. * This behavior provides a way to translate dynamic data by keeping translations
  29. * in a separate table linked to the original record from another one. Translated
  30. * fields can be configured to override those in the main table when fetched or
  31. * put aside into another property for the same entity.
  32. *
  33. * If you wish to override fields, you need to call the `locale` method in this
  34. * behavior for setting the language you want to fetch from the translations table.
  35. *
  36. * If you want to bring all or certain languages for each of the fetched records,
  37. * you can use the custom `translations` finders that is exposed to the table.
  38. */
  39. class TranslateBehavior extends Behavior
  40. {
  41. /**
  42. * Table instance
  43. *
  44. * @var \Cake\ORM\Table
  45. */
  46. protected $_table;
  47. /**
  48. * The locale name that will be used to override fields in the bound table
  49. * from the translations table
  50. *
  51. * @var string
  52. */
  53. protected $_locale;
  54. /**
  55. * Instance of Table responsible for translating
  56. *
  57. * @var \Cake\ORM\Table
  58. */
  59. protected $_translationTable;
  60. /**
  61. * Default config
  62. *
  63. * These are merged with user-provided configuration when the behavior is used.
  64. *
  65. * @var array
  66. */
  67. protected $_defaultConfig = [
  68. 'implementedFinders' => ['translations' => 'findTranslations'],
  69. 'implementedMethods' => ['locale' => 'locale'],
  70. 'fields' => [],
  71. 'translationTable' => 'I18n',
  72. 'defaultLocale' => '',
  73. 'referenceName' => '',
  74. 'allowEmptyTranslations' => true,
  75. 'onlyTranslated' => false,
  76. 'strategy' => 'subquery'
  77. ];
  78. /**
  79. * Constructor
  80. *
  81. * @param \Cake\ORM\Table $table The table this behavior is attached to.
  82. * @param array $config The config for this behavior.
  83. */
  84. public function __construct(Table $table, array $config = [])
  85. {
  86. $config += [
  87. 'defaultLocale' => I18n::defaultLocale(),
  88. 'referenceName' => $this->_referenceName($table)
  89. ];
  90. parent::__construct($table, $config);
  91. }
  92. /**
  93. * Initialize hook
  94. *
  95. * @param array $config The config for this behavior.
  96. * @return void
  97. */
  98. public function initialize(array $config)
  99. {
  100. $this->_translationTable = TableRegistry::get($this->_config['translationTable']);
  101. $this->setupFieldAssociations(
  102. $this->_config['fields'],
  103. $this->_config['translationTable'],
  104. $this->_config['referenceName'],
  105. $this->_config['strategy']
  106. );
  107. }
  108. /**
  109. * Creates the associations between the bound table and every field passed to
  110. * this method.
  111. *
  112. * Additionally it creates a `i18n` HasMany association that will be
  113. * used for fetching all translations for each record in the bound table
  114. *
  115. * @param array $fields list of fields to create associations for
  116. * @param string $table the table name to use for storing each field translation
  117. * @param string $model the model field value
  118. * @param string $strategy the strategy used in the _i18n association
  119. *
  120. * @return void
  121. */
  122. public function setupFieldAssociations($fields, $table, $model, $strategy)
  123. {
  124. $targetAlias = $this->_translationTable->alias();
  125. $alias = $this->_table->alias();
  126. $filter = $this->_config['onlyTranslated'];
  127. foreach ($fields as $field) {
  128. $name = $alias . '_' . $field . '_translation';
  129. if (!TableRegistry::exists($name)) {
  130. $fieldTable = TableRegistry::get($name, [
  131. 'className' => $table,
  132. 'alias' => $name,
  133. 'table' => $this->_translationTable->table()
  134. ]);
  135. } else {
  136. $fieldTable = TableRegistry::get($name);
  137. }
  138. $conditions = [
  139. $name . '.model' => $model,
  140. $name . '.field' => $field,
  141. ];
  142. if (!$this->_config['allowEmptyTranslations']) {
  143. $conditions[$name . '.content !='] = '';
  144. }
  145. $this->_table->hasOne($name, [
  146. 'targetTable' => $fieldTable,
  147. 'foreignKey' => 'foreign_key',
  148. 'joinType' => $filter ? 'INNER' : 'LEFT',
  149. 'conditions' => $conditions,
  150. 'propertyName' => $field . '_translation'
  151. ]);
  152. }
  153. $conditions = ["$targetAlias.model" => $model];
  154. if (!$this->_config['allowEmptyTranslations']) {
  155. $conditions["$targetAlias.content !="] = '';
  156. }
  157. $this->_table->hasMany($targetAlias, [
  158. 'className' => $table,
  159. 'foreignKey' => 'foreign_key',
  160. 'strategy' => $strategy,
  161. 'conditions' => $conditions,
  162. 'propertyName' => '_i18n',
  163. 'dependent' => true
  164. ]);
  165. }
  166. /**
  167. * Callback method that listens to the `beforeFind` event in the bound
  168. * table. It modifies the passed query by eager loading the translated fields
  169. * and adding a formatter to copy the values into the main table records.
  170. *
  171. * @param \Cake\Event\Event $event The beforeFind event that was fired.
  172. * @param \Cake\ORM\Query $query Query
  173. * @param \ArrayObject $options The options for the query
  174. * @return void
  175. */
  176. public function beforeFind(Event $event, Query $query, $options)
  177. {
  178. $locale = $this->locale();
  179. if ($locale === $this->config('defaultLocale')) {
  180. return;
  181. }
  182. $conditions = function ($field, $locale, $query, $select) {
  183. return function ($q) use ($field, $locale, $query, $select) {
  184. $q->where([$q->repository()->aliasField('locale') => $locale]);
  185. if ($query->autoFields() ||
  186. in_array($field, $select, true) ||
  187. in_array($this->_table->aliasField($field), $select, true)
  188. ) {
  189. $q->select(['id', 'content']);
  190. }
  191. return $q;
  192. };
  193. };
  194. $contain = [];
  195. $fields = $this->_config['fields'];
  196. $alias = $this->_table->alias();
  197. $select = $query->clause('select');
  198. $changeFilter = isset($options['filterByCurrentLocale']) &&
  199. $options['filterByCurrentLocale'] !== $this->_config['onlyTranslated'];
  200. foreach ($fields as $field) {
  201. $name = $alias . '_' . $field . '_translation';
  202. $contain[$name]['queryBuilder'] = $conditions(
  203. $field,
  204. $locale,
  205. $query,
  206. $select
  207. );
  208. if ($changeFilter) {
  209. $filter = $options['filterByCurrentLocale'] ? 'INNER' : 'LEFT';
  210. $contain[$name]['joinType'] = $filter;
  211. }
  212. }
  213. $query->contain($contain);
  214. $query->formatResults(function ($results) use ($locale) {
  215. return $this->_rowMapper($results, $locale);
  216. }, $query::PREPEND);
  217. }
  218. /**
  219. * Modifies the entity before it is saved so that translated fields are persisted
  220. * in the database too.
  221. *
  222. * @param \Cake\Event\Event $event The beforeSave event that was fired
  223. * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
  224. * @param \ArrayObject $options the options passed to the save method
  225. * @return void
  226. */
  227. public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options)
  228. {
  229. $locale = $entity->get('_locale') ?: $this->locale();
  230. $newOptions = [$this->_translationTable->alias() => ['validate' => false]];
  231. $options['associated'] = $newOptions + $options['associated'];
  232. $this->_bundleTranslatedFields($entity);
  233. $bundled = $entity->get('_i18n') ?: [];
  234. if ($locale === $this->config('defaultLocale')) {
  235. return;
  236. }
  237. $values = $entity->extract($this->_config['fields'], true);
  238. $fields = array_keys($values);
  239. $primaryKey = (array)$this->_table->primaryKey();
  240. $key = $entity->get(current($primaryKey));
  241. $model = $this->_config['referenceName'];
  242. $preexistent = $this->_translationTable->find()
  243. ->select(['id', 'field'])
  244. ->where(['field IN' => $fields, 'locale' => $locale, 'foreign_key' => $key, 'model' => $model])
  245. ->bufferResults(false)
  246. ->indexBy('field');
  247. $modified = [];
  248. foreach ($preexistent as $field => $translation) {
  249. $translation->set('content', $values[$field]);
  250. $modified[$field] = $translation;
  251. }
  252. $new = array_diff_key($values, $modified);
  253. foreach ($new as $field => $content) {
  254. $new[$field] = new Entity(compact('locale', 'field', 'content', 'model'), [
  255. 'useSetters' => false,
  256. 'markNew' => true
  257. ]);
  258. }
  259. $entity->set('_i18n', array_merge($bundled, array_values($modified + $new)));
  260. $entity->set('_locale', $locale, ['setter' => false]);
  261. $entity->dirty('_locale', false);
  262. foreach ($fields as $field) {
  263. $entity->dirty($field, false);
  264. }
  265. }
  266. /**
  267. * Unsets the temporary `_i18n` property after the entity has been saved
  268. *
  269. * @param \Cake\Event\Event $event The beforeSave event that was fired
  270. * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
  271. * @return void
  272. */
  273. public function afterSave(Event $event, EntityInterface $entity)
  274. {
  275. $entity->unsetProperty('_i18n');
  276. }
  277. /**
  278. * Sets all future finds for the bound table to also fetch translated fields for
  279. * the passed locale. If no value is passed, it returns the currently configured
  280. * locale
  281. *
  282. * @param string|null $locale The locale to use for fetching translated records
  283. * @return string
  284. */
  285. public function locale($locale = null)
  286. {
  287. if ($locale === null) {
  288. return $this->_locale ?: I18n::locale();
  289. }
  290. return $this->_locale = (string)$locale;
  291. }
  292. /**
  293. * Custom finder method used to retrieve all translations for the found records.
  294. * Fetched translations can be filtered by locale by passing the `locales` key
  295. * in the options array.
  296. *
  297. * Translated values will be found for each entity under the property `_translations`,
  298. * containing an array indexed by locale name.
  299. *
  300. * ### Example:
  301. *
  302. * ```
  303. * $article = $articles->find('translations', ['locales' => ['eng', 'deu'])->first();
  304. * $englishTranslatedFields = $article->get('_translations')['eng'];
  305. * ```
  306. *
  307. * If the `locales` array is not passed, it will bring all translations found
  308. * for each record.
  309. *
  310. * @param \Cake\ORM\Query $query The original query to modify
  311. * @param array $options Options
  312. * @return \Cake\ORM\Query
  313. */
  314. public function findTranslations(Query $query, array $options)
  315. {
  316. $locales = isset($options['locales']) ? $options['locales'] : [];
  317. $targetAlias = $this->_translationTable->alias();
  318. return $query
  319. ->contain([$targetAlias => function ($q) use ($locales, $targetAlias) {
  320. if ($locales) {
  321. $q->where(["$targetAlias.locale IN" => $locales]);
  322. }
  323. return $q;
  324. }])
  325. ->formatResults([$this, 'groupTranslations'], $query::PREPEND);
  326. }
  327. /**
  328. * Determine the reference name to use for a given table
  329. *
  330. * The reference name is usually derived from the class name of the table object
  331. * (PostsTable -> Posts), however for autotable instances it is derived from
  332. * the database table the object points at - or as a last resort, the alias
  333. * of the autotable instance.
  334. *
  335. * @param \Cake\ORM\Table $table The table class to get a reference name for.
  336. * @return string
  337. */
  338. protected function _referenceName(Table $table)
  339. {
  340. $name = namespaceSplit(get_class($table));
  341. $name = substr(end($name), 0, -5);
  342. if (empty($name)) {
  343. $name = $table->table() ?: $table->alias();
  344. $name = Inflector::camelize($name);
  345. }
  346. return $name;
  347. }
  348. /**
  349. * Modifies the results from a table find in order to merge the translated fields
  350. * into each entity for a given locale.
  351. *
  352. * @param \Cake\Datasource\ResultSetInterface $results Results to map.
  353. * @param string $locale Locale string
  354. * @return \Cake\Collection\Collection
  355. */
  356. protected function _rowMapper($results, $locale)
  357. {
  358. return $results->map(function ($row) use ($locale) {
  359. if ($row === null) {
  360. return $row;
  361. }
  362. $hydrated = !is_array($row);
  363. foreach ($this->_config['fields'] as $field) {
  364. $name = $field . '_translation';
  365. $translation = isset($row[$name]) ? $row[$name] : null;
  366. if ($translation === null || $translation === false) {
  367. unset($row[$name]);
  368. continue;
  369. }
  370. $content = isset($translation['content']) ? $translation['content'] : null;
  371. if ($content !== null) {
  372. $row[$field] = $content;
  373. }
  374. unset($row[$name]);
  375. }
  376. $row['_locale'] = $locale;
  377. if ($hydrated) {
  378. $row->clean();
  379. }
  380. return $row;
  381. });
  382. }
  383. /**
  384. * Modifies the results from a table find in order to merge full translation records
  385. * into each entity under the `_translations` key
  386. *
  387. * @param \Cake\Datasource\ResultSetInterface $results Results to modify.
  388. * @return \Cake\Collection\Collection
  389. */
  390. public function groupTranslations($results)
  391. {
  392. return $results->map(function ($row) {
  393. $translations = (array)$row->get('_i18n');
  394. $grouped = new Collection($translations);
  395. $result = [];
  396. foreach ($grouped->combine('field', 'content', 'locale') as $locale => $keys) {
  397. $entityClass = $this->_table->entityClass();
  398. $translation = new $entityClass($keys + ['locale' => $locale], [
  399. 'markNew' => false,
  400. 'useSetters' => false,
  401. 'markClean' => true
  402. ]);
  403. $result[$locale] = $translation;
  404. }
  405. $options = ['setter' => false, 'guard' => false];
  406. $row->set('_translations', $result, $options);
  407. unset($row['_i18n']);
  408. $row->clean();
  409. return $row;
  410. });
  411. }
  412. /**
  413. * Helper method used to generated multiple translated field entities
  414. * out of the data found in the `_translations` property in the passed
  415. * entity. The result will be put into its `_i18n` property
  416. *
  417. * @param \Cake\Datasource\EntityInterface $entity Entity
  418. * @return void
  419. */
  420. protected function _bundleTranslatedFields($entity)
  421. {
  422. $translations = (array)$entity->get('_translations');
  423. if (empty($translations) && !$entity->dirty('_translations')) {
  424. return;
  425. }
  426. $fields = $this->_config['fields'];
  427. $primaryKey = (array)$this->_table->primaryKey();
  428. $key = $entity->get(current($primaryKey));
  429. $find = [];
  430. foreach ($translations as $lang => $translation) {
  431. foreach ($fields as $field) {
  432. if (!$translation->dirty($field)) {
  433. continue;
  434. }
  435. $find[] = ['locale' => $lang, 'field' => $field, 'foreign_key' => $key];
  436. $contents[] = new Entity(['content' => $translation->get($field)], [
  437. 'useSetters' => false
  438. ]);
  439. }
  440. }
  441. if (empty($find)) {
  442. return;
  443. }
  444. $results = $this->_findExistingTranslations($find);
  445. foreach ($find as $i => $translation) {
  446. if (!empty($results[$i])) {
  447. $contents[$i]->set('id', $results[$i], ['setter' => false]);
  448. $contents[$i]->isNew(false);
  449. } else {
  450. $translation['model'] = $this->_config['referenceName'];
  451. $contents[$i]->set($translation, ['setter' => false, 'guard' => false]);
  452. $contents[$i]->isNew(true);
  453. }
  454. }
  455. $entity->set('_i18n', $contents);
  456. }
  457. /**
  458. * Returns the ids found for each of the condition arrays passed for the translations
  459. * table. Each records is indexed by the corresponding position to the conditions array
  460. *
  461. * @param array $ruleSet an array of arary of conditions to be used for finding each
  462. * @return array
  463. */
  464. protected function _findExistingTranslations($ruleSet)
  465. {
  466. $association = $this->_table->association($this->_translationTable->alias());
  467. $query = $association->find()
  468. ->select(['id', 'num' => 0])
  469. ->where(current($ruleSet))
  470. ->hydrate(false)
  471. ->bufferResults(false);
  472. unset($ruleSet[0]);
  473. foreach ($ruleSet as $i => $conditions) {
  474. $q = $association->find()
  475. ->select(['id', 'num' => $i])
  476. ->where($conditions);
  477. $query->unionAll($q);
  478. }
  479. return $query->combine('num', 'id')->toArray();
  480. }
  481. }