Browse Source

Move the guts of TranslateBehavior into strategy class.

ADmad 8 years ago
parent
commit
832a6fa851
2 changed files with 729 additions and 513 deletions
  1. 671 0
      src/ORM/Behavior/Translate/EavStrategy.php
  2. 58 513
      src/ORM/Behavior/TranslateBehavior.php

+ 671 - 0
src/ORM/Behavior/Translate/EavStrategy.php

@@ -0,0 +1,671 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.0.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\ORM\Behavior\Translate;
+
+use ArrayObject;
+use Cake\Collection\Collection;
+use Cake\Collection\CollectionInterface;
+use Cake\Core\InstanceConfigTrait;
+use Cake\Datasource\EntityInterface;
+use Cake\Datasource\QueryInterface;
+use Cake\Event\Event;
+use Cake\I18n\I18n;
+use Cake\ORM\Entity;
+use Cake\ORM\Locator\LocatorAwareTrait;
+use Cake\ORM\PropertyMarshalInterface;
+use Cake\ORM\Query;
+use Cake\ORM\Table;
+
+/**
+ * This behavior provides a way to translate dynamic data by keeping translations
+ * in a separate table linked to the original record from another one. Translated
+ * fields can be configured to override those in the main table when fetched or
+ * put aside into another property for the same entity.
+ *
+ * If you wish to override fields, you need to call the `locale` method in this
+ * behavior for setting the language you want to fetch from the translations table.
+ *
+ * If you want to bring all or certain languages for each of the fetched records,
+ * you can use the custom `translations` finders that is exposed to the table.
+ */
+class EavStrategy implements PropertyMarshalInterface
+{
+
+    use InstanceConfigTrait;
+    use LocatorAwareTrait;
+
+    /**
+     * Table instance
+     *
+     * @var \Cake\ORM\Table
+     */
+    protected $table;
+
+    /**
+     * The locale name that will be used to override fields in the bound table
+     * from the translations table
+     *
+     * @var string
+     */
+    protected $locale;
+
+    /**
+     * Instance of Table responsible for translating
+     *
+     * @var \Cake\ORM\Table
+     */
+    protected $translationTable;
+
+    /**
+     * Default config
+     *
+     * These are merged with user-provided configuration when the behavior is used.
+     *
+     * @var array
+     */
+    protected $_defaultConfig = [
+        'fields' => [],
+        'translationTable' => 'I18n',
+        'defaultLocale' => null,
+        'referenceName' => null,
+        'allowEmptyTranslations' => true,
+        'onlyTranslated' => false,
+        'strategy' => 'subquery',
+        'tableLocator' => null,
+        'validator' => false,
+    ];
+
+    /**
+     * Constructor
+     *
+     * @param \Cake\ORM\Table $table The table this behavior is attached to.
+     * @param array $config The config for this behavior.
+     */
+    public function __construct(Table $table, array $config = [])
+    {
+        if (isset($config['tableLocator'])) {
+            $this->_tableLocator = $config['tableLocator'];
+        }
+
+        $this->setConfig($config);
+        $this->table = $table;
+        $this->translationTable = $this->getTableLocator()->get($this->_config['translationTable']);
+
+        $this->setupFieldAssociations(
+            $this->_config['fields'],
+            $this->_config['translationTable'],
+            $this->_config['referenceName'],
+            $this->_config['strategy']
+        );
+    }
+
+    /**
+     * Return translation table instance.
+     *
+     * @return \Cake\ORM\Table
+     */
+    public function getTranslationTable(): Table
+    {
+        return $this->translationTable;
+    }
+
+    /**
+     * Creates the associations between the bound table and every field passed to
+     * this method.
+     *
+     * Additionally it creates a `i18n` HasMany association that will be
+     * used for fetching all translations for each record in the bound table
+     *
+     * @param array $fields list of fields to create associations for
+     * @param string $table the table name to use for storing each field translation
+     * @param string $model the model field value
+     * @param string $strategy the strategy used in the _i18n association
+     *
+     * @return void
+     */
+    protected function setupFieldAssociations($fields, $table, $model, $strategy)
+    {
+        $targetAlias = $this->translationTable->getAlias();
+        $alias = $this->table->getAlias();
+        $filter = $this->_config['onlyTranslated'];
+        $tableLocator = $this->getTableLocator();
+
+        foreach ($fields as $field) {
+            $name = $alias . '_' . $field . '_translation';
+
+            if (!$tableLocator->exists($name)) {
+                $fieldTable = $tableLocator->get($name, [
+                    'className' => $table,
+                    'alias' => $name,
+                    'table' => $this->translationTable->getTable(),
+                ]);
+            } else {
+                $fieldTable = $tableLocator->get($name);
+            }
+
+            $conditions = [
+                $name . '.model' => $model,
+                $name . '.field' => $field,
+            ];
+            if (!$this->_config['allowEmptyTranslations']) {
+                $conditions[$name . '.content !='] = '';
+            }
+
+            $this->table->hasOne($name, [
+                'targetTable' => $fieldTable,
+                'foreignKey' => 'foreign_key',
+                'joinType' => $filter ? QueryInterface::JOIN_TYPE_INNER : QueryInterface::JOIN_TYPE_LEFT,
+                'conditions' => $conditions,
+                'propertyName' => $field . '_translation',
+            ]);
+        }
+
+        $conditions = ["$targetAlias.model" => $model];
+        if (!$this->_config['allowEmptyTranslations']) {
+            $conditions["$targetAlias.content !="] = '';
+        }
+
+        $this->table->hasMany($targetAlias, [
+            'className' => $table,
+            'foreignKey' => 'foreign_key',
+            'strategy' => $strategy,
+            'conditions' => $conditions,
+            'propertyName' => '_i18n',
+            'dependent' => true,
+        ]);
+    }
+
+    /**
+     * Callback method that listens to the `beforeFind` event in the bound
+     * table. It modifies the passed query by eager loading the translated fields
+     * and adding a formatter to copy the values into the main table records.
+     *
+     * @param \Cake\Event\Event $event The beforeFind event that was fired.
+     * @param \Cake\ORM\Query $query Query
+     * @param \ArrayObject $options The options for the query
+     * @return void
+     */
+    public function beforeFind(Event $event, Query $query, ArrayObject $options): void
+    {
+        $locale = $this->getLocale();
+
+        if ($locale === $this->getConfig('defaultLocale')) {
+            return;
+        }
+
+        $conditions = function ($field, $locale, $query, $select) {
+            return function ($q) use ($field, $locale, $query, $select) {
+                /* @var \Cake\Datasource\QueryInterface $q */
+                $q->where([$q->getRepository()->aliasField('locale') => $locale]);
+
+                /* @var \Cake\ORM\Query $query */
+                if ($query->isAutoFieldsEnabled() ||
+                    in_array($field, $select, true) ||
+                    in_array($this->table->aliasField($field), $select, true)
+                ) {
+                    $q->select(['id', 'content']);
+                }
+
+                return $q;
+            };
+        };
+
+        $contain = [];
+        $fields = $this->_config['fields'];
+        $alias = $this->table->getAlias();
+        $select = $query->clause('select');
+
+        $changeFilter = isset($options['filterByCurrentLocale']) &&
+            $options['filterByCurrentLocale'] !== $this->_config['onlyTranslated'];
+
+        foreach ($fields as $field) {
+            $name = $alias . '_' . $field . '_translation';
+
+            $contain[$name]['queryBuilder'] = $conditions(
+                $field,
+                $locale,
+                $query,
+                $select
+            );
+
+            if ($changeFilter) {
+                $filter = $options['filterByCurrentLocale'] ? QueryInterface::JOIN_TYPE_INNER : QueryInterface::JOIN_TYPE_LEFT;
+                $contain[$name]['joinType'] = $filter;
+            }
+        }
+
+        $query->contain($contain);
+        $query->formatResults(function ($results) use ($locale) {
+            return $this->rowMapper($results, $locale);
+        }, $query::PREPEND);
+    }
+
+    /**
+     * Modifies the entity before it is saved so that translated fields are persisted
+     * in the database too.
+     *
+     * @param \Cake\Event\Event $event The beforeSave event that was fired
+     * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
+     * @param \ArrayObject $options the options passed to the save method
+     * @return void
+     */
+    public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options): void
+    {
+        $locale = $entity->get('_locale') ?: $this->getLocale();
+        $newOptions = [$this->translationTable->getAlias() => ['validate' => false]];
+        $options['associated'] = $newOptions + $options['associated'];
+
+        // Check early if empty translations are present in the entity.
+        // If this is the case, unset them to prevent persistence.
+        // This only applies if $this->_config['allowEmptyTranslations'] is false
+        if ($this->_config['allowEmptyTranslations'] === false) {
+            $this->unsetEmptyFields($entity);
+        }
+
+        $this->bundleTranslatedFields($entity);
+        $bundled = $entity->get('_i18n') ?: [];
+        $noBundled = count($bundled) === 0;
+
+        // No additional translation records need to be saved,
+        // as the entity is in the default locale.
+        if ($noBundled && $locale === $this->getConfig('defaultLocale')) {
+            return;
+        }
+
+        $values = $entity->extract($this->_config['fields'], true);
+        $fields = array_keys($values);
+        $noFields = empty($fields);
+
+        // If there are no fields and no bundled translations, or both fields
+        // in the default locale and bundled translations we can
+        // skip the remaining logic as its not necessary.
+        if ($noFields && $noBundled || ($fields && $bundled)) {
+            return;
+        }
+
+        $primaryKey = (array)$this->table->getPrimaryKey();
+        $key = $entity->get(current($primaryKey));
+
+        // When we have no key and bundled translations, we
+        // need to mark the entity dirty so the root
+        // entity persists.
+        if ($noFields && $bundled && !$key) {
+            foreach ($this->_config['fields'] as $field) {
+                $entity->setDirty($field, true);
+            }
+
+            return;
+        }
+
+        if ($noFields) {
+            return;
+        }
+
+        $model = $this->_config['referenceName'];
+        $preexistent = $this->translationTable->find()
+            ->select(['id', 'field'])
+            ->where([
+                'field IN' => $fields,
+                'locale' => $locale,
+                'foreign_key' => $key,
+                'model' => $model,
+            ])
+            ->enableBufferedResults(false)
+            ->all()
+            ->indexBy('field');
+
+        $modified = [];
+        foreach ($preexistent as $field => $translation) {
+            $translation->set('content', $values[$field]);
+            $modified[$field] = $translation;
+        }
+
+        $new = array_diff_key($values, $modified);
+        foreach ($new as $field => $content) {
+            $new[$field] = new Entity(compact('locale', 'field', 'content', 'model'), [
+                'useSetters' => false,
+                'markNew' => true,
+            ]);
+        }
+
+        $entity->set('_i18n', array_merge($bundled, array_values($modified + $new)));
+        $entity->set('_locale', $locale, ['setter' => false]);
+        $entity->setDirty('_locale', false);
+
+        foreach ($fields as $field) {
+            $entity->setDirty($field, false);
+        }
+    }
+
+    /**
+     * Unsets the temporary `_i18n` property after the entity has been saved
+     *
+     * @param \Cake\Event\Event $event The beforeSave event that was fired
+     * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
+     * @return void
+     */
+    public function afterSave(Event $event, EntityInterface $entity): void
+    {
+        $entity->unsetProperty('_i18n');
+    }
+
+    /**
+     * Add in `_translations` marshalling handlers. You can disable marshalling
+     * of translations by setting `'translations' => false` in the options
+     * provided to `Table::newEntity()` or `Table::patchEntity()`.
+     *
+     * {@inheritDoc}
+     */
+    public function buildMarshalMap($marshaller, $map, $options)
+    {
+        if (isset($options['translations']) && !$options['translations']) {
+            return [];
+        }
+
+        return [
+            '_translations' => function ($value, $entity) use ($marshaller, $options) {
+                /* @var \Cake\Datasource\EntityInterface $entity */
+                $translations = $entity->get('_translations');
+                foreach ($this->_config['fields'] as $field) {
+                    $options['validate'] = $this->_config['validator'];
+                    $errors = [];
+                    if (!is_array($value)) {
+                        return null;
+                    }
+                    foreach ($value as $language => $fields) {
+                        if (!isset($translations[$language])) {
+                            $translations[$language] = $this->table->newEntity();
+                        }
+                        $marshaller->merge($translations[$language], $fields, $options);
+                        if ((bool)$translations[$language]->getErrors()) {
+                            $errors[$language] = $translations[$language]->getErrors();
+                        }
+                    }
+                    // Set errors into the root entity, so validation errors
+                    // match the original form data position.
+                    $entity->setErrors($errors);
+                }
+
+                return $translations;
+            },
+        ];
+    }
+
+    /**
+     * Sets the locale that should be used for all future find and save operations on
+     * the table where this behavior is attached to.
+     *
+     * When fetching records, the behavior will include the content for the locale set
+     * via this method, and likewise when saving data, it will save the data in that
+     * locale.
+     *
+     * Note that in case an entity has a `_locale` property set, that locale will win
+     * over the locale set via this method (and over the globally configured one for
+     * that matter)!
+     *
+     * @param string|null $locale The locale to use for fetching and saving records. Pass `null`
+     * in order to unset the current locale, and to make the behavior fall back to using the
+     * globally configured locale.
+     * @return $this
+     * @see \Cake\ORM\Behavior\TranslateBehavior::getLocale()
+     * @link https://book.cakephp.org/3.0/en/orm/behaviors/translate.html#retrieving-one-language-without-using-i18n-locale
+     * @link https://book.cakephp.org/3.0/en/orm/behaviors/translate.html#saving-in-another-language
+     */
+    public function setLocale(?string $locale)
+    {
+        $this->locale = $locale;
+
+        return $this;
+    }
+
+    /**
+     * Returns the current locale.
+     *
+     * If no locale has been explicitly set via `setLocale()`, this method will return
+     * the currently configured global locale.
+     *
+     * @return string
+     * @see \Cake\I18n\I18n::getLocale()
+     * @see \Cake\ORM\Behavior\TranslateBehavior::setLocale()
+     */
+    public function getLocale(): string
+    {
+        return $this->locale ?: I18n::getLocale();
+    }
+
+    /**
+     * Returns a fully aliased field name for translated fields.
+     *
+     * If the requested field is configured as a translation field, the `content`
+     * field with an alias of a corresponding association is returned. Table-aliased
+     * field name is returned for all other fields.
+     *
+     * @param string $field Field name to be aliased.
+     * @return string
+     */
+    public function translationField(string $field): string
+    {
+        $table = $this->table;
+        if ($this->getLocale() === $this->getConfig('defaultLocale')) {
+            return $table->aliasField($field);
+        }
+        $associationName = $table->getAlias() . '_' . $field . '_translation';
+
+        if ($table->associations()->has($associationName)) {
+            return $associationName . '.content';
+        }
+
+        return $table->aliasField($field);
+    }
+
+    /**
+     * Modifies the results from a table find in order to merge the translated fields
+     * into each entity for a given locale.
+     *
+     * @param \Cake\Datasource\ResultSetInterface $results Results to map.
+     * @param string $locale Locale string
+     * @return \Cake\Collection\CollectionInterface
+     */
+    protected function rowMapper($results, $locale)
+    {
+        return $results->map(function ($row) use ($locale) {
+            if ($row === null) {
+                return $row;
+            }
+            $hydrated = !is_array($row);
+
+            foreach ($this->_config['fields'] as $field) {
+                $name = $field . '_translation';
+                $translation = isset($row[$name]) ? $row[$name] : null;
+
+                if ($translation === null || $translation === false) {
+                    unset($row[$name]);
+                    continue;
+                }
+
+                $content = isset($translation['content']) ? $translation['content'] : null;
+                if ($content !== null) {
+                    $row[$field] = $content;
+                }
+
+                unset($row[$name]);
+            }
+
+            $row['_locale'] = $locale;
+            if ($hydrated) {
+                /* @var \Cake\Datasource\EntityInterface $row */
+                $row->clean();
+            }
+
+            return $row;
+        });
+    }
+
+    /**
+     * Modifies the results from a table find in order to merge full translation records
+     * into each entity under the `_translations` key
+     *
+     * @param \Cake\Datasource\ResultSetInterface $results Results to modify.
+     * @return \Cake\Collection\CollectionInterface
+     */
+    public function groupTranslations($results): CollectionInterface
+    {
+        return $results->map(function ($row) {
+            if (!$row instanceof EntityInterface) {
+                return $row;
+            }
+            $translations = (array)$row->get('_i18n');
+            if (empty($translations) && $row->get('_translations')) {
+                return $row;
+            }
+            $grouped = new Collection($translations);
+
+            $result = [];
+            foreach ($grouped->combine('field', 'content', 'locale') as $locale => $keys) {
+                $entityClass = $this->table->getEntityClass();
+                $translation = new $entityClass($keys + ['locale' => $locale], [
+                    'markNew' => false,
+                    'useSetters' => false,
+                    'markClean' => true,
+                ]);
+                $result[$locale] = $translation;
+            }
+
+            $options = ['setter' => false, 'guard' => false];
+            $row->set('_translations', $result, $options);
+            unset($row['_i18n']);
+            $row->clean();
+
+            return $row;
+        });
+    }
+
+    /**
+     * Helper method used to generated multiple translated field entities
+     * out of the data found in the `_translations` property in the passed
+     * entity. The result will be put into its `_i18n` property
+     *
+     * @param \Cake\Datasource\EntityInterface $entity Entity
+     * @return void
+     */
+    protected function bundleTranslatedFields($entity)
+    {
+        $translations = (array)$entity->get('_translations');
+
+        if (empty($translations) && !$entity->isDirty('_translations')) {
+            return;
+        }
+
+        $fields = $this->_config['fields'];
+        $primaryKey = (array)$this->table->getPrimaryKey();
+        $key = $entity->get(current($primaryKey));
+        $find = [];
+        $contents = [];
+
+        foreach ($translations as $lang => $translation) {
+            foreach ($fields as $field) {
+                if (!$translation->isDirty($field)) {
+                    continue;
+                }
+                $find[] = ['locale' => $lang, 'field' => $field, 'foreign_key' => $key];
+                $contents[] = new Entity(['content' => $translation->get($field)], [
+                    'useSetters' => false,
+                ]);
+            }
+        }
+
+        if (empty($find)) {
+            return;
+        }
+
+        $results = $this->findExistingTranslations($find);
+
+        foreach ($find as $i => $translation) {
+            if (!empty($results[$i])) {
+                $contents[$i]->set('id', $results[$i], ['setter' => false]);
+                $contents[$i]->isNew(false);
+            } else {
+                $translation['model'] = $this->_config['referenceName'];
+                $contents[$i]->set($translation, ['setter' => false, 'guard' => false]);
+                $contents[$i]->isNew(true);
+            }
+        }
+
+        $entity->set('_i18n', $contents);
+    }
+
+    /**
+     * Unset empty translations to avoid persistence.
+     *
+     * Should only be called if $this->_config['allowEmptyTranslations'] is false.
+     *
+     * @param \Cake\Datasource\EntityInterface $entity The entity to check for empty translations fields inside.
+     * @return void
+     */
+    protected function unsetEmptyFields($entity)
+    {
+        $translations = (array)$entity->get('_translations');
+        foreach ($translations as $locale => $translation) {
+            $fields = $translation->extract($this->_config['fields'], false);
+            foreach ($fields as $field => $value) {
+                if (strlen($value) === 0) {
+                    $translation->unsetProperty($field);
+                }
+            }
+
+            $translation = $translation->extract($this->_config['fields']);
+
+            // If now, the current locale property is empty,
+            // unset it completely.
+            if (empty(array_filter($translation))) {
+                unset($entity->get('_translations')[$locale]);
+            }
+        }
+
+        // If now, the whole _translations property is empty,
+        // unset it completely and return
+        if (empty($entity->get('_translations'))) {
+            $entity->unsetProperty('_translations');
+        }
+    }
+
+    /**
+     * Returns the ids found for each of the condition arrays passed for the translations
+     * table. Each records is indexed by the corresponding position to the conditions array
+     *
+     * @param array $ruleSet an array of arary of conditions to be used for finding each
+     * @return array
+     */
+    protected function findExistingTranslations($ruleSet)
+    {
+        $association = $this->table->getAssociation($this->translationTable->getAlias());
+
+        $query = $association->find()
+            ->select(['id', 'num' => 0])
+            ->where(current($ruleSet))
+            ->enableHydration(false)
+            ->enableBufferedResults(false);
+
+        unset($ruleSet[0]);
+        foreach ($ruleSet as $i => $conditions) {
+            $q = $association->find()
+                ->select(['id', 'num' => $i])
+                ->where($conditions);
+            $query->unionAll($q);
+        }
+
+        return $query->all()->combine('num', 'id')->toArray();
+    }
+}

+ 58 - 513
src/ORM/Behavior/TranslateBehavior.php

@@ -14,15 +14,9 @@
  */
 namespace Cake\ORM\Behavior;
 
-use ArrayObject;
-use Cake\Collection\Collection;
-use Cake\Datasource\EntityInterface;
-use Cake\Datasource\QueryInterface;
-use Cake\Event\Event;
 use Cake\I18n\I18n;
 use Cake\ORM\Behavior;
-use Cake\ORM\Entity;
-use Cake\ORM\Locator\LocatorAwareTrait;
+use Cake\ORM\Behavior\Translate\EavStrategy;
 use Cake\ORM\PropertyMarshalInterface;
 use Cake\ORM\Query;
 use Cake\ORM\Table;
@@ -42,31 +36,6 @@ use Cake\Utility\Inflector;
  */
 class TranslateBehavior extends Behavior implements PropertyMarshalInterface
 {
-
-    use LocatorAwareTrait;
-
-    /**
-     * Table instance
-     *
-     * @var \Cake\ORM\Table
-     */
-    protected $_table;
-
-    /**
-     * The locale name that will be used to override fields in the bound table
-     * from the translations table
-     *
-     * @var string
-     */
-    protected $_locale;
-
-    /**
-     * Instance of Table responsible for translating
-     *
-     * @var \Cake\ORM\Table
-     */
-    protected $_translationTable;
-
     /**
      * Default config
      *
@@ -79,20 +48,27 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface
         'implementedMethods' => [
             'setLocale' => 'setLocale',
             'getLocale' => 'getLocale',
-            'translationField' => 'translationField'
+            'translationField' => 'translationField',
         ],
+        'strategyClass' => EavStrategy::class,
         'fields' => [],
-        'translationTable' => 'I18n',
         'defaultLocale' => '',
         'referenceName' => '',
         'allowEmptyTranslations' => true,
         'onlyTranslated' => false,
         'strategy' => 'subquery',
         'tableLocator' => null,
-        'validator' => false
+        'validator' => false,
     ];
 
     /**
+     * Translation strategy instance.
+     *
+     * @var object|null
+     */
+    protected $strategy;
+
+    /**
      * Constructor
      *
      * @param \Cake\ORM\Table $table The table this behavior is attached to.
@@ -102,13 +78,9 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface
     {
         $config += [
             'defaultLocale' => I18n::getDefaultLocale(),
-            'referenceName' => $this->_referenceName($table)
+            'referenceName' => $this->referenceName($table),
         ];
 
-        if (isset($config['tableLocator'])) {
-            $this->_tableLocator = $config['tableLocator'];
-        }
-
         parent::__construct($table, $config);
     }
 
@@ -120,254 +92,54 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface
      */
     public function initialize(array $config)
     {
-        $this->_translationTable = $this->getTableLocator()->get($this->_config['translationTable']);
-
-        $this->setupFieldAssociations(
-            $this->_config['fields'],
-            $this->_config['translationTable'],
-            $this->_config['referenceName'],
-            $this->_config['strategy']
-        );
-    }
-
-    /**
-     * Creates the associations between the bound table and every field passed to
-     * this method.
-     *
-     * Additionally it creates a `i18n` HasMany association that will be
-     * used for fetching all translations for each record in the bound table
-     *
-     * @param array $fields list of fields to create associations for
-     * @param string $table the table name to use for storing each field translation
-     * @param string $model the model field value
-     * @param string $strategy the strategy used in the _i18n association
-     *
-     * @return void
-     */
-    public function setupFieldAssociations($fields, $table, $model, $strategy)
-    {
-        $targetAlias = $this->_translationTable->getAlias();
-        $alias = $this->_table->getAlias();
-        $filter = $this->_config['onlyTranslated'];
-        $tableLocator = $this->getTableLocator();
-
-        foreach ($fields as $field) {
-            $name = $alias . '_' . $field . '_translation';
-
-            if (!$tableLocator->exists($name)) {
-                $fieldTable = $tableLocator->get($name, [
-                    'className' => $table,
-                    'alias' => $name,
-                    'table' => $this->_translationTable->getTable()
-                ]);
-            } else {
-                $fieldTable = $tableLocator->get($name);
-            }
-
-            $conditions = [
-                $name . '.model' => $model,
-                $name . '.field' => $field,
-            ];
-            if (!$this->_config['allowEmptyTranslations']) {
-                $conditions[$name . '.content !='] = '';
-            }
-
-            $this->_table->hasOne($name, [
-                'targetTable' => $fieldTable,
-                'foreignKey' => 'foreign_key',
-                'joinType' => $filter ? QueryInterface::JOIN_TYPE_INNER : QueryInterface::JOIN_TYPE_LEFT,
-                'conditions' => $conditions,
-                'propertyName' => $field . '_translation'
-            ]);
-        }
-
-        $conditions = ["$targetAlias.model" => $model];
-        if (!$this->_config['allowEmptyTranslations']) {
-            $conditions["$targetAlias.content !="] = '';
-        }
-
-        $this->_table->hasMany($targetAlias, [
-            'className' => $table,
-            'foreignKey' => 'foreign_key',
-            'strategy' => $strategy,
-            'conditions' => $conditions,
-            'propertyName' => '_i18n',
-            'dependent' => true
-        ]);
+        $this->getStrategy();
     }
 
     /**
-     * Callback method that listens to the `beforeFind` event in the bound
-     * table. It modifies the passed query by eager loading the translated fields
-     * and adding a formatter to copy the values into the main table records.
+     * Get strategy class instance.
      *
-     * @param \Cake\Event\Event $event The beforeFind event that was fired.
-     * @param \Cake\ORM\Query $query Query
-     * @param \ArrayObject $options The options for the query
-     * @return void
+     * @return object
      */
-    public function beforeFind(Event $event, Query $query, $options)
+    public function getStrategy()
     {
-        $locale = $this->getLocale();
-
-        if ($locale === $this->getConfig('defaultLocale')) {
-            return;
+        if ($this->strategy !== null) {
+            return $this->strategy;
         }
 
-        $conditions = function ($field, $locale, $query, $select) {
-            return function ($q) use ($field, $locale, $query, $select) {
-                /* @var \Cake\Datasource\QueryInterface $q */
-                $q->where([$q->getRepository()->aliasField('locale') => $locale]);
-
-                /* @var \Cake\ORM\Query $query */
-                if ($query->isAutoFieldsEnabled() ||
-                    in_array($field, $select, true) ||
-                    in_array($this->_table->aliasField($field), $select, true)
-                ) {
-                    $q->select(['id', 'content']);
-                }
-
-                return $q;
-            };
-        };
-
-        $contain = [];
-        $fields = $this->_config['fields'];
-        $alias = $this->_table->getAlias();
-        $select = $query->clause('select');
-
-        $changeFilter = isset($options['filterByCurrentLocale']) &&
-            $options['filterByCurrentLocale'] !== $this->_config['onlyTranslated'];
-
-        foreach ($fields as $field) {
-            $name = $alias . '_' . $field . '_translation';
-
-            $contain[$name]['queryBuilder'] = $conditions(
-                $field,
-                $locale,
-                $query,
-                $select
-            );
-
-            if ($changeFilter) {
-                $filter = $options['filterByCurrentLocale'] ? QueryInterface::JOIN_TYPE_INNER : QueryInterface::JOIN_TYPE_LEFT;
-                $contain[$name]['joinType'] = $filter;
-            }
-        }
+        $config = array_diff_key(
+            $this->_config,
+            ['implementedFinders', 'implementedMethods', 'strategyClass']
+        );
+        $this->strategy = new $this->_config['strategyClass']($this->_table, $config);
 
-        $query->contain($contain);
-        $query->formatResults(function ($results) use ($locale) {
-            return $this->_rowMapper($results, $locale);
-        }, $query::PREPEND);
+        return $this->strategy;
     }
 
     /**
-     * Modifies the entity before it is saved so that translated fields are persisted
-     * in the database too.
+     * Set strategy class instance.
      *
-     * @param \Cake\Event\Event $event The beforeSave event that was fired
-     * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
-     * @param \ArrayObject $options the options passed to the save method
-     * @return void
+     * @param object $strategy Strategy class instance.
+     * @return $this
      */
-    public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options)
+    public function setStrategy($strategy)
     {
-        $locale = $entity->get('_locale') ?: $this->getLocale();
-        $newOptions = [$this->_translationTable->getAlias() => ['validate' => false]];
-        $options['associated'] = $newOptions + $options['associated'];
-
-        // Check early if empty translations are present in the entity.
-        // If this is the case, unset them to prevent persistence.
-        // This only applies if $this->_config['allowEmptyTranslations'] is false
-        if ($this->_config['allowEmptyTranslations'] === false) {
-            $this->_unsetEmptyFields($entity);
-        }
-
-        $this->_bundleTranslatedFields($entity);
-        $bundled = $entity->get('_i18n') ?: [];
-        $noBundled = count($bundled) === 0;
-
-        // No additional translation records need to be saved,
-        // as the entity is in the default locale.
-        if ($noBundled && $locale === $this->getConfig('defaultLocale')) {
-            return;
-        }
-
-        $values = $entity->extract($this->_config['fields'], true);
-        $fields = array_keys($values);
-        $noFields = empty($fields);
-
-        // If there are no fields and no bundled translations, or both fields
-        // in the default locale and bundled translations we can
-        // skip the remaining logic as its not necessary.
-        if ($noFields && $noBundled || ($fields && $bundled)) {
-            return;
-        }
+        $this->strategy = $strategy;
 
-        $primaryKey = (array)$this->_table->getPrimaryKey();
-        $key = $entity->get(current($primaryKey));
-
-        // When we have no key and bundled translations, we
-        // need to mark the entity dirty so the root
-        // entity persists.
-        if ($noFields && $bundled && !$key) {
-            foreach ($this->_config['fields'] as $field) {
-                $entity->setDirty($field, true);
-            }
-
-            return;
-        }
-
-        if ($noFields) {
-            return;
-        }
-
-        $model = $this->_config['referenceName'];
-        $preexistent = $this->_translationTable->find()
-            ->select(['id', 'field'])
-            ->where([
-                'field IN' => $fields,
-                'locale' => $locale,
-                'foreign_key' => $key,
-                'model' => $model
-            ])
-            ->enableBufferedResults(false)
-            ->all()
-            ->indexBy('field');
-
-        $modified = [];
-        foreach ($preexistent as $field => $translation) {
-            $translation->set('content', $values[$field]);
-            $modified[$field] = $translation;
-        }
-
-        $new = array_diff_key($values, $modified);
-        foreach ($new as $field => $content) {
-            $new[$field] = new Entity(compact('locale', 'field', 'content', 'model'), [
-                'useSetters' => false,
-                'markNew' => true
-            ]);
-        }
-
-        $entity->set('_i18n', array_merge($bundled, array_values($modified + $new)));
-        $entity->set('_locale', $locale, ['setter' => false]);
-        $entity->setDirty('_locale', false);
-
-        foreach ($fields as $field) {
-            $entity->setDirty($field, false);
-        }
+        return $this;
     }
 
     /**
-     * Unsets the temporary `_i18n` property after the entity has been saved
+     * Gets the Model callbacks this behavior is interested in.
      *
-     * @param \Cake\Event\Event $event The beforeSave event that was fired
-     * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
-     * @return void
+     * @return array
      */
-    public function afterSave(Event $event, EntityInterface $entity)
+    public function implementedEvents()
     {
-        $entity->unsetProperty('_i18n');
+        return [
+            'Model.beforeFind' => 'beforeFind',
+            'Model.beforeSave' => 'beforeSave',
+            'Model.afterSave' => 'afterSave',
+        ];
     }
 
     /**
@@ -379,37 +151,7 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface
      */
     public function buildMarshalMap($marshaller, $map, $options)
     {
-        if (isset($options['translations']) && !$options['translations']) {
-            return [];
-        }
-
-        return [
-            '_translations' => function ($value, $entity) use ($marshaller, $options) {
-                /* @var \Cake\Datasource\EntityInterface $entity */
-                $translations = $entity->get('_translations');
-                foreach ($this->_config['fields'] as $field) {
-                    $options['validate'] = $this->_config['validator'];
-                    $errors = [];
-                    if (!is_array($value)) {
-                        return null;
-                    }
-                    foreach ($value as $language => $fields) {
-                        if (!isset($translations[$language])) {
-                            $translations[$language] = $this->_table->newEntity();
-                        }
-                        $marshaller->merge($translations[$language], $fields, $options);
-                        if ((bool)$translations[$language]->getErrors()) {
-                            $errors[$language] = $translations[$language]->getErrors();
-                        }
-                    }
-                    // Set errors into the root entity, so validation errors
-                    // match the original form data position.
-                    $entity->setErrors($errors);
-                }
-
-                return $translations;
-            }
-        ];
+        return $this->getStrategy()->buildMarshalMap($marshaller, $map, $options);
     }
 
     /**
@@ -434,7 +176,7 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface
      */
     public function setLocale($locale)
     {
-        $this->_locale = $locale;
+        $this->getStrategy()->setLocale($locale);
 
         return $this;
     }
@@ -451,7 +193,7 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface
      */
     public function getLocale()
     {
-        return $this->_locale ?: I18n::getLocale();
+        return $this->getStrategy()->getLocale();
     }
 
     /**
@@ -466,17 +208,7 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface
      */
     public function translationField($field)
     {
-        $table = $this->_table;
-        if ($this->getLocale() === $this->getConfig('defaultLocale')) {
-            return $table->aliasField($field);
-        }
-        $associationName = $table->getAlias() . '_' . $field . '_translation';
-
-        if ($table->associations()->has($associationName)) {
-            return $associationName . '.content';
-        }
-
-        return $table->aliasField($field);
+        return $this->getStrategy()->translationField($field);
     }
 
     /**
@@ -503,8 +235,8 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface
      */
     public function findTranslations(Query $query, array $options)
     {
-        $locales = isset($options['locales']) ? $options['locales'] : [];
-        $targetAlias = $this->_translationTable->getAlias();
+        $locales = $options['locales'] ?? [];
+        $targetAlias = $this->getStrategy()->getTranslationTable()->getAlias();
 
         return $query
             ->contain([$targetAlias => function ($query) use ($locales, $targetAlias) {
@@ -515,7 +247,19 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface
 
                 return $query;
             }])
-            ->formatResults([$this, 'groupTranslations'], $query::PREPEND);
+            ->formatResults([$this->getStrategy(), 'groupTranslations'], $query::PREPEND);
+    }
+
+    /**
+     * Proxy method calls to strategy class instance.
+     *
+     * @param string $method Method name.
+     * @param array $args Method arguments.
+     * @return mixed
+     */
+    public function __call($method, $args)
+    {
+        return call_user_func_array([$this->strategy, $method], $args);
     }
 
     /**
@@ -529,7 +273,7 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface
      * @param \Cake\ORM\Table $table The table class to get a reference name for.
      * @return string
      */
-    protected function _referenceName(Table $table)
+    protected function referenceName(Table $table)
     {
         $name = namespaceSplit(get_class($table));
         $name = substr(end($name), 0, -5);
@@ -540,203 +284,4 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface
 
         return $name;
     }
-
-    /**
-     * Modifies the results from a table find in order to merge the translated fields
-     * into each entity for a given locale.
-     *
-     * @param \Cake\Datasource\ResultSetInterface $results Results to map.
-     * @param string $locale Locale string
-     * @return \Cake\Collection\CollectionInterface
-     */
-    protected function _rowMapper($results, $locale)
-    {
-        return $results->map(function ($row) use ($locale) {
-            if ($row === null) {
-                return $row;
-            }
-            $hydrated = !is_array($row);
-
-            foreach ($this->_config['fields'] as $field) {
-                $name = $field . '_translation';
-                $translation = isset($row[$name]) ? $row[$name] : null;
-
-                if ($translation === null || $translation === false) {
-                    unset($row[$name]);
-                    continue;
-                }
-
-                $content = isset($translation['content']) ? $translation['content'] : null;
-                if ($content !== null) {
-                    $row[$field] = $content;
-                }
-
-                unset($row[$name]);
-            }
-
-            $row['_locale'] = $locale;
-            if ($hydrated) {
-                /* @var \Cake\Datasource\EntityInterface $row */
-                $row->clean();
-            }
-
-            return $row;
-        });
-    }
-
-    /**
-     * Modifies the results from a table find in order to merge full translation records
-     * into each entity under the `_translations` key
-     *
-     * @param \Cake\Datasource\ResultSetInterface $results Results to modify.
-     * @return \Cake\Collection\CollectionInterface
-     */
-    public function groupTranslations($results)
-    {
-        return $results->map(function ($row) {
-            if (!$row instanceof EntityInterface) {
-                return $row;
-            }
-            $translations = (array)$row->get('_i18n');
-            if (empty($translations) && $row->get('_translations')) {
-                return $row;
-            }
-            $grouped = new Collection($translations);
-
-            $result = [];
-            foreach ($grouped->combine('field', 'content', 'locale') as $locale => $keys) {
-                $entityClass = $this->_table->getEntityClass();
-                $translation = new $entityClass($keys + ['locale' => $locale], [
-                    'markNew' => false,
-                    'useSetters' => false,
-                    'markClean' => true
-                ]);
-                $result[$locale] = $translation;
-            }
-
-            $options = ['setter' => false, 'guard' => false];
-            $row->set('_translations', $result, $options);
-            unset($row['_i18n']);
-            $row->clean();
-
-            return $row;
-        });
-    }
-
-    /**
-     * Helper method used to generated multiple translated field entities
-     * out of the data found in the `_translations` property in the passed
-     * entity. The result will be put into its `_i18n` property
-     *
-     * @param \Cake\Datasource\EntityInterface $entity Entity
-     * @return void
-     */
-    protected function _bundleTranslatedFields($entity)
-    {
-        $translations = (array)$entity->get('_translations');
-
-        if (empty($translations) && !$entity->isDirty('_translations')) {
-            return;
-        }
-
-        $fields = $this->_config['fields'];
-        $primaryKey = (array)$this->_table->getPrimaryKey();
-        $key = $entity->get(current($primaryKey));
-        $find = [];
-        $contents = [];
-
-        foreach ($translations as $lang => $translation) {
-            foreach ($fields as $field) {
-                if (!$translation->isDirty($field)) {
-                    continue;
-                }
-                $find[] = ['locale' => $lang, 'field' => $field, 'foreign_key' => $key];
-                $contents[] = new Entity(['content' => $translation->get($field)], [
-                    'useSetters' => false
-                ]);
-            }
-        }
-
-        if (empty($find)) {
-            return;
-        }
-
-        $results = $this->_findExistingTranslations($find);
-
-        foreach ($find as $i => $translation) {
-            if (!empty($results[$i])) {
-                $contents[$i]->set('id', $results[$i], ['setter' => false]);
-                $contents[$i]->isNew(false);
-            } else {
-                $translation['model'] = $this->_config['referenceName'];
-                $contents[$i]->set($translation, ['setter' => false, 'guard' => false]);
-                $contents[$i]->isNew(true);
-            }
-        }
-
-        $entity->set('_i18n', $contents);
-    }
-
-    /**
-     * Unset empty translations to avoid persistence.
-     *
-     * Should only be called if $this->_config['allowEmptyTranslations'] is false.
-     *
-     * @param \Cake\Datasource\EntityInterface $entity The entity to check for empty translations fields inside.
-     * @return void
-     */
-    protected function _unsetEmptyFields(EntityInterface $entity)
-    {
-        $translations = (array)$entity->get('_translations');
-        foreach ($translations as $locale => $translation) {
-            $fields = $translation->extract($this->_config['fields'], false);
-            foreach ($fields as $field => $value) {
-                if (strlen($value) === 0) {
-                    $translation->unsetProperty($field);
-                }
-            }
-
-            $translation = $translation->extract($this->_config['fields']);
-
-            // If now, the current locale property is empty,
-            // unset it completely.
-            if (empty(array_filter($translation))) {
-                unset($entity->get('_translations')[$locale]);
-            }
-        }
-
-        // If now, the whole _translations property is empty,
-        // unset it completely and return
-        if (empty($entity->get('_translations'))) {
-            $entity->unsetProperty('_translations');
-        }
-    }
-
-    /**
-     * Returns the ids found for each of the condition arrays passed for the translations
-     * table. Each records is indexed by the corresponding position to the conditions array
-     *
-     * @param array $ruleSet an array of arary of conditions to be used for finding each
-     * @return array
-     */
-    protected function _findExistingTranslations($ruleSet)
-    {
-        $association = $this->_table->getAssociation($this->_translationTable->getAlias());
-
-        $query = $association->find()
-            ->select(['id', 'num' => 0])
-            ->where(current($ruleSet))
-            ->enableHydration(false)
-            ->enableBufferedResults(false);
-
-        unset($ruleSet[0]);
-        foreach ($ruleSet as $i => $conditions) {
-            $q = $association->find()
-                ->select(['id', 'num' => $i])
-                ->where($conditions);
-            $query->unionAll($q);
-        }
-
-        return $query->all()->combine('num', 'id')->toArray();
-    }
 }