ShadowTableStrategy.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
  5. * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  6. *
  7. * Licensed under The MIT License
  8. * For full copyright and license information, please see the LICENSE.txt
  9. * Redistributions of files must retain the above copyright notice.
  10. *
  11. * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  12. * @link https://cakephp.org CakePHP(tm) Project
  13. * @since 4.0.0
  14. * @license https://opensource.org/licenses/mit-license.php MIT License
  15. */
  16. namespace Cake\ORM\Behavior\Translate;
  17. use ArrayObject;
  18. use Cake\Collection\CollectionInterface;
  19. use Cake\Core\InstanceConfigTrait;
  20. use Cake\Database\Expression\FieldInterface;
  21. use Cake\Datasource\EntityInterface;
  22. use Cake\Event\EventInterface;
  23. use Cake\ORM\Locator\LocatorAwareTrait;
  24. use Cake\ORM\Marshaller;
  25. use Cake\ORM\Query;
  26. use Cake\ORM\Table;
  27. /**
  28. * This class provides a way to translate dynamic data by keeping translations
  29. * in a separate shadow table where each row corresponds to a row of primary table.
  30. */
  31. class ShadowTableStrategy implements TranslateStrategyInterface
  32. {
  33. use InstanceConfigTrait;
  34. use LocatorAwareTrait;
  35. use TranslateStrategyTrait {
  36. buildMarshalMap as private _buildMarshalMap;
  37. }
  38. /**
  39. * Default config
  40. *
  41. * These are merged with user-provided configuration.
  42. *
  43. * @var array
  44. */
  45. protected $_defaultConfig = [
  46. 'fields' => [],
  47. 'defaultLocale' => null,
  48. 'referenceName' => null,
  49. 'allowEmptyTranslations' => true,
  50. 'onlyTranslated' => false,
  51. 'strategy' => 'subquery',
  52. 'tableLocator' => null,
  53. 'validator' => false,
  54. ];
  55. /**
  56. * Constructor
  57. *
  58. * @param \Cake\ORM\Table $table Table instance.
  59. * @param array $config Configuration.
  60. */
  61. public function __construct(Table $table, array $config = [])
  62. {
  63. $tableAlias = $table->getAlias();
  64. list($plugin) = pluginSplit($table->getRegistryAlias(), true);
  65. $tableReferenceName = $config['referenceName'];
  66. $config += [
  67. 'mainTableAlias' => $tableAlias,
  68. 'translationTable' => $plugin . $tableReferenceName . 'Translations',
  69. 'hasOneAlias' => $tableAlias . 'Translation',
  70. ];
  71. if (isset($config['tableLocator'])) {
  72. $this->_tableLocator = $config['tableLocator'];
  73. }
  74. $this->setConfig($config);
  75. $this->table = $table;
  76. $this->translationTable = $this->getTableLocator()->get($this->_config['translationTable']);
  77. $this->setupAssociations();
  78. }
  79. /**
  80. * Create a hasMany association for all records.
  81. *
  82. * Don't create a hasOne association here as the join conditions are modified
  83. * in before find - so create/modify it there.
  84. *
  85. * @return void
  86. */
  87. protected function setupAssociations()
  88. {
  89. $config = $this->getConfig();
  90. $this->table->hasMany($config['translationTable'], [
  91. 'className' => $config['translationTable'],
  92. 'foreignKey' => 'id',
  93. 'strategy' => $config['strategy'],
  94. 'propertyName' => '_i18n',
  95. 'dependent' => true,
  96. ]);
  97. }
  98. /**
  99. * Callback method that listens to the `beforeFind` event in the bound
  100. * table. It modifies the passed query by eager loading the translated fields
  101. * and adding a formatter to copy the values into the main table records.
  102. *
  103. * @param \Cake\Event\EventInterface $event The beforeFind event that was fired.
  104. * @param \Cake\ORM\Query $query Query.
  105. * @param \ArrayObject $options The options for the query.
  106. * @return void
  107. */
  108. public function beforeFind(EventInterface $event, Query $query, ArrayObject $options)
  109. {
  110. $locale = $this->getLocale();
  111. if ($locale === $this->getConfig('defaultLocale')) {
  112. return;
  113. }
  114. $config = $this->getConfig();
  115. if (isset($options['filterByCurrentLocale'])) {
  116. $joinType = $options['filterByCurrentLocale'] ? 'INNER' : 'LEFT';
  117. } else {
  118. $joinType = $config['onlyTranslated'] ? 'INNER' : 'LEFT';
  119. }
  120. $this->table->hasOne($config['hasOneAlias'], [
  121. 'foreignKey' => ['id'],
  122. 'joinType' => $joinType,
  123. 'propertyName' => 'translation',
  124. 'className' => $config['translationTable'],
  125. 'conditions' => [
  126. $config['hasOneAlias'] . '.locale' => $locale,
  127. ],
  128. ]);
  129. $fieldsAdded = $this->addFieldsToQuery($query, $config);
  130. $orderByTranslatedField = $this->iterateClause($query, 'order', $config);
  131. $filteredByTranslatedField = $this->traverseClause($query, 'where', $config);
  132. if (!$fieldsAdded && !$orderByTranslatedField && !$filteredByTranslatedField) {
  133. return;
  134. }
  135. $query->contain([$config['hasOneAlias']]);
  136. $query->formatResults(function ($results) use ($locale) {
  137. return $this->rowMapper($results, $locale);
  138. }, $query::PREPEND);
  139. }
  140. /**
  141. * Add translation fields to query.
  142. *
  143. * If the query is using autofields (directly or implicitly) add the
  144. * main table's fields to the query first.
  145. *
  146. * Only add translations for fields that are in the main table, always
  147. * add the locale field though.
  148. *
  149. * @param \Cake\ORM\Query $query The query to check.
  150. * @param array $config The config to use for adding fields.
  151. * @return bool Whether a join to the translation table is required.
  152. */
  153. protected function addFieldsToQuery($query, array $config)
  154. {
  155. if ($query->isAutoFieldsEnabled()) {
  156. return true;
  157. }
  158. $select = array_filter($query->clause('select'), function ($field) {
  159. return is_string($field);
  160. });
  161. if (!$select) {
  162. return true;
  163. }
  164. $alias = $config['mainTableAlias'];
  165. $joinRequired = false;
  166. foreach ($this->translatedFields() as $field) {
  167. if (array_intersect($select, [$field, "$alias.$field"])) {
  168. $joinRequired = true;
  169. $query->select($query->aliasField($field, $config['hasOneAlias']));
  170. }
  171. }
  172. if ($joinRequired) {
  173. $query->select($query->aliasField('locale', $config['hasOneAlias']));
  174. }
  175. return $joinRequired;
  176. }
  177. /**
  178. * Iterate over a clause to alias fields.
  179. *
  180. * The objective here is to transparently prevent ambiguous field errors by
  181. * prefixing fields with the appropriate table alias. This method currently
  182. * expects to receive an order clause only.
  183. *
  184. * @param \Cake\ORM\Query $query the query to check.
  185. * @param string $name The clause name.
  186. * @param array $config The config to use for adding fields.
  187. * @return bool Whether a join to the translation table is required.
  188. */
  189. protected function iterateClause($query, $name = '', $config = [])
  190. {
  191. $clause = $query->clause($name);
  192. if (!$clause || !$clause->count()) {
  193. return false;
  194. }
  195. $alias = $config['hasOneAlias'];
  196. $fields = $this->translatedFields();
  197. $mainTableAlias = $config['mainTableAlias'];
  198. $mainTableFields = $this->mainFields();
  199. $joinRequired = false;
  200. $clause->iterateParts(
  201. function ($c, &$field) use ($fields, $alias, $mainTableAlias, $mainTableFields, &$joinRequired) {
  202. if (!is_string($field) || strpos($field, '.')) {
  203. return $c;
  204. }
  205. if (in_array($field, $fields)) {
  206. $joinRequired = true;
  207. $field = "$alias.$field";
  208. } elseif (in_array($field, $mainTableFields)) {
  209. $field = "$mainTableAlias.$field";
  210. }
  211. return $c;
  212. }
  213. );
  214. return $joinRequired;
  215. }
  216. /**
  217. * Traverse over a clause to alias fields.
  218. *
  219. * The objective here is to transparently prevent ambiguous field errors by
  220. * prefixing fields with the appropriate table alias. This method currently
  221. * expects to receive a where clause only.
  222. *
  223. * @param \Cake\ORM\Query $query the query to check.
  224. * @param string $name The clause name.
  225. * @param array $config The config to use for adding fields.
  226. * @return bool Whether a join to the translation table is required.
  227. */
  228. protected function traverseClause($query, $name = '', $config = [])
  229. {
  230. $clause = $query->clause($name);
  231. if (!$clause || !$clause->count()) {
  232. return false;
  233. }
  234. $alias = $config['hasOneAlias'];
  235. $fields = $this->translatedFields();
  236. $mainTableAlias = $config['mainTableAlias'];
  237. $mainTableFields = $this->mainFields();
  238. $joinRequired = false;
  239. $clause->traverse(
  240. function ($expression) use ($fields, $alias, $mainTableAlias, $mainTableFields, &$joinRequired) {
  241. if (!($expression instanceof FieldInterface)) {
  242. return;
  243. }
  244. $field = $expression->getField();
  245. if (!is_string($field) || strpos($field, '.')) {
  246. return;
  247. }
  248. if (in_array($field, $fields)) {
  249. $joinRequired = true;
  250. $expression->setField("$alias.$field");
  251. return;
  252. }
  253. if (in_array($field, $mainTableFields)) {
  254. $expression->setField("$mainTableAlias.$field");
  255. }
  256. }
  257. );
  258. return $joinRequired;
  259. }
  260. /**
  261. * Modifies the entity before it is saved so that translated fields are persisted
  262. * in the database too.
  263. *
  264. * @param \Cake\Event\EventInterface $event The beforeSave event that was fired.
  265. * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved.
  266. * @param \ArrayObject $options the options passed to the save method.
  267. * @return void
  268. */
  269. public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options)
  270. {
  271. $locale = $entity->get('_locale') ?: $this->getLocale();
  272. $newOptions = [$this->translationTable->getAlias() => ['validate' => false]];
  273. $options['associated'] = $newOptions + $options['associated'];
  274. // Check early if empty translations are present in the entity.
  275. // If this is the case, unset them to prevent persistence.
  276. // This only applies if $this->_config['allowEmptyTranslations'] is false
  277. if ($this->_config['allowEmptyTranslations'] === false) {
  278. $this->unsetEmptyFields($entity);
  279. }
  280. $this->bundleTranslatedFields($entity);
  281. $bundled = $entity->get('_i18n') ?: [];
  282. $noBundled = count($bundled) === 0;
  283. // No additional translation records need to be saved,
  284. // as the entity is in the default locale.
  285. if ($noBundled && $locale === $this->getConfig('defaultLocale')) {
  286. return;
  287. }
  288. $values = $entity->extract($this->translatedFields(), true);
  289. $fields = array_keys($values);
  290. $noFields = empty($fields);
  291. // If there are no fields and no bundled translations, or both fields
  292. // in the default locale and bundled translations we can
  293. // skip the remaining logic as its not necessary.
  294. if ($noFields && $noBundled || ($fields && $bundled)) {
  295. return;
  296. }
  297. $primaryKey = (array)$this->table->getPrimaryKey();
  298. $id = $entity->get(current($primaryKey));
  299. // When we have no key and bundled translations, we
  300. // need to mark the entity dirty so the root
  301. // entity persists.
  302. if ($noFields && $bundled && !$id) {
  303. foreach ($this->translatedFields() as $field) {
  304. $entity->setDirty($field, true);
  305. }
  306. return;
  307. }
  308. if ($noFields) {
  309. return;
  310. }
  311. $where = ['id' => $id, 'locale' => $locale];
  312. $translation = $this->translationTable->find()
  313. ->select(array_merge(['id', 'locale'], $fields))
  314. ->where($where)
  315. ->disableBufferedResults()
  316. ->first();
  317. if ($translation) {
  318. $translation->set($values);
  319. } else {
  320. $translation = $this->translationTable->newEntity(
  321. $where + $values,
  322. [
  323. 'useSetters' => false,
  324. 'markNew' => true,
  325. ]
  326. );
  327. }
  328. $entity->set('_i18n', array_merge($bundled, [$translation]));
  329. $entity->set('_locale', $locale, ['setter' => false]);
  330. $entity->setDirty('_locale', false);
  331. foreach ($fields as $field) {
  332. $entity->setDirty($field, false);
  333. }
  334. }
  335. /**
  336. * {@inheritDoc}
  337. */
  338. public function buildMarshalMap(Marshaller $marshaller, array $map, array $options): array
  339. {
  340. $this->translatedFields();
  341. return $this->_buildMarshalMap($marshaller, $map, $options);
  342. }
  343. /**
  344. * Returns a fully aliased field name for translated fields.
  345. *
  346. * If the requested field is configured as a translation field, field with
  347. * an alias of a corresponding association is returned. Table-aliased
  348. * field name is returned for all other fields.
  349. *
  350. * @param string $field Field name to be aliased.
  351. * @return string
  352. */
  353. public function translationField(string $field): string
  354. {
  355. if ($this->getLocale() === $this->getConfig('defaultLocale')) {
  356. return $this->table->aliasField($field);
  357. }
  358. $translatedFields = $this->translatedFields();
  359. if (in_array($field, $translatedFields)) {
  360. return $this->getConfig('hasOneAlias') . '.' . $field;
  361. }
  362. return $this->table->aliasField($field);
  363. }
  364. /**
  365. * Modifies the results from a table find in order to merge the translated
  366. * fields into each entity for a given locale.
  367. *
  368. * @param \Cake\Datasource\ResultSetInterface $results Results to map.
  369. * @param string $locale Locale string
  370. * @return \Cake\Collection\CollectionInterface
  371. */
  372. protected function rowMapper($results, $locale)
  373. {
  374. $allowEmpty = $this->_config['allowEmptyTranslations'];
  375. return $results->map(function ($row) use ($allowEmpty) {
  376. if ($row === null) {
  377. return $row;
  378. }
  379. $hydrated = !is_array($row);
  380. if (empty($row['translation'])) {
  381. $row['_locale'] = $this->getLocale();
  382. unset($row['translation']);
  383. if ($hydrated) {
  384. $row->clean();
  385. }
  386. return $row;
  387. }
  388. /** @var \Cake\ORM\Entity $translation|array */
  389. $translation = $row['translation'];
  390. $keys = $hydrated ? $translation->getVisible() : array_keys($translation);
  391. foreach ($keys as $field) {
  392. if ($field === 'locale') {
  393. $row['_locale'] = $translation[$field];
  394. continue;
  395. }
  396. if ($translation[$field] !== null) {
  397. if ($allowEmpty || $translation[$field] !== '') {
  398. $row[$field] = $translation[$field];
  399. }
  400. }
  401. }
  402. unset($row['translation']);
  403. if ($hydrated) {
  404. $row->clean();
  405. }
  406. return $row;
  407. });
  408. }
  409. /**
  410. * Modifies the results from a table find in order to merge full translation
  411. * records into each entity under the `_translations` key.
  412. *
  413. * @param \Cake\Datasource\ResultSetInterface $results Results to modify.
  414. * @return \Cake\Collection\CollectionInterface
  415. */
  416. public function groupTranslations($results): CollectionInterface
  417. {
  418. return $results->map(function ($row) {
  419. $translations = (array)$row['_i18n'];
  420. if (empty($translations) && $row->get('_translations')) {
  421. return $row;
  422. }
  423. $result = [];
  424. foreach ($translations as $translation) {
  425. unset($translation['id']);
  426. $result[$translation['locale']] = $translation;
  427. }
  428. $row['_translations'] = $result;
  429. unset($row['_i18n']);
  430. if ($row instanceof EntityInterface) {
  431. $row->clean();
  432. }
  433. return $row;
  434. });
  435. }
  436. /**
  437. * Helper method used to generated multiple translated field entities
  438. * out of the data found in the `_translations` property in the passed
  439. * entity. The result will be put into its `_i18n` property.
  440. *
  441. * @param \Cake\Datasource\EntityInterface $entity Entity.
  442. * @return void
  443. */
  444. protected function bundleTranslatedFields($entity)
  445. {
  446. $translations = (array)$entity->get('_translations');
  447. if (empty($translations) && !$entity->isDirty('_translations')) {
  448. return;
  449. }
  450. $primaryKey = (array)$this->table->getPrimaryKey();
  451. $key = $entity->get(current($primaryKey));
  452. foreach ($translations as $lang => $translation) {
  453. if (!$translation->id) {
  454. $update = [
  455. 'id' => $key,
  456. 'locale' => $lang,
  457. ];
  458. $translation->set($update, ['guard' => false]);
  459. }
  460. }
  461. $entity->set('_i18n', $translations);
  462. }
  463. /**
  464. * Lazy define and return the main table fields.
  465. *
  466. * @return array
  467. */
  468. protected function mainFields()
  469. {
  470. $fields = $this->getConfig('mainTableFields');
  471. if ($fields) {
  472. return $fields;
  473. }
  474. $fields = $this->table->getSchema()->columns();
  475. $this->setConfig('mainTableFields', $fields);
  476. return $fields;
  477. }
  478. /**
  479. * Lazy define and return the translation table fields.
  480. *
  481. * @return array
  482. */
  483. protected function translatedFields()
  484. {
  485. $fields = $this->getConfig('fields');
  486. if ($fields) {
  487. return $fields;
  488. }
  489. $table = $this->translationTable;
  490. $fields = $table->getSchema()->columns();
  491. $fields = array_values(array_diff($fields, ['id', 'locale']));
  492. $this->setConfig('fields', $fields);
  493. return $fields;
  494. }
  495. }