Browse Source

Add ShadowTableStrategy for TranslateBehavior.

ADmad 8 years ago
parent
commit
57ec655798

+ 739 - 0
src/ORM/Behavior/Translate/ShadowTableStrategy.php

@@ -0,0 +1,739 @@
+<?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\Core\InstanceConfigTrait;
+use Cake\Database\Expression\FieldInterface;
+use Cake\Datasource\EntityInterface;
+use Cake\Event\Event;
+use Cake\I18n\I18n;
+use Cake\ORM\Locator\LocatorAwareTrait;
+use Cake\ORM\PropertyMarshalInterface;
+use Cake\ORM\Query;
+use Cake\ORM\Table;
+
+/**
+ * ShadowTable strategy
+ */
+class ShadowTableStrategy 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' => [],
+        'defaultLocale' => null,
+        'referenceName' => null,
+        'allowEmptyTranslations' => true,
+        'onlyTranslated' => false,
+        'strategy' => 'subquery',
+        'tableLocator' => null,
+        'validator' => false,
+    ];
+
+    /**
+     * Constructor
+     *
+     * @param \Cake\ORM\Table $table Table instance
+     * @param array $config Configuration
+     */
+    public function __construct(Table $table, array $config = [])
+    {
+        $tableAlias = $table->getAlias();
+        list($plugin) = pluginSplit($table->getRegistryAlias(), true);
+        $tableReferenceName = $config['referenceName'];
+
+        $config += [
+            'mainTableAlias' => $tableAlias,
+            'translationTable' => $plugin . $tableReferenceName . 'Translations',
+            'hasOneAlias' => $tableAlias . 'Translation',
+        ];
+
+        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;
+    }
+
+    /**
+     * 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();
+    }
+
+    /**
+     * Create a hasMany association for all records
+     *
+     * Don't create a hasOne association here as the join conditions are modified
+     * in before find - so create/modify it there
+     *
+     * @param array $fields - ignored
+     * @param string $table - ignored
+     * @param string $fieldConditions - ignored
+     * @param string $strategy the strategy used in the _i18n association
+     *
+     * @return void
+     */
+    public function setupFieldAssociations($fields, $table, $fieldConditions, $strategy)
+    {
+        $config = $this->getConfig();
+
+        $this->table->hasMany($config['translationTable'], [
+            'className' => $config['translationTable'],
+            'foreignKey' => 'id',
+            'strategy' => $strategy,
+            '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)
+    {
+        $locale = $this->getLocale();
+
+        if ($locale === $this->getConfig('defaultLocale')) {
+            return;
+        }
+
+        $config = $this->getConfig();
+
+        if (isset($options['filterByCurrentLocale'])) {
+            $joinType = $options['filterByCurrentLocale'] ? 'INNER' : 'LEFT';
+        } else {
+            $joinType = $config['onlyTranslated'] ? 'INNER' : 'LEFT';
+        }
+
+        $this->table->hasOne($config['hasOneAlias'], [
+            'foreignKey' => ['id'],
+            'joinType' => $joinType,
+            'propertyName' => 'translation',
+            'className' => $config['translationTable'],
+            'conditions' => [
+                $config['hasOneAlias'] . '.locale' => $locale,
+            ],
+        ]);
+
+        $fieldsAdded = $this->addFieldsToQuery($query, $config);
+        $orderByTranslatedField = $this->iterateClause($query, 'order', $config);
+        $filteredByTranslatedField = $this->traverseClause($query, 'where', $config);
+
+        if (!$fieldsAdded && !$orderByTranslatedField && !$filteredByTranslatedField) {
+            return;
+        }
+
+        $query->contain([$config['hasOneAlias']]);
+
+        $query->formatResults(function ($results) use ($locale) {
+            return $this->rowMapper($results, $locale);
+        }, $query::PREPEND);
+    }
+
+    /**
+     * Add translation fields to query
+     *
+     * If the query is using autofields (directly or implicitly) add the
+     * main table's fields to the query first.
+     *
+     * Only add translations for fields that are in the main table, always
+     * add the locale field though.
+     *
+     * @param \Cake\ORM\Query $query the query to check
+     * @param array $config the config to use for adding fields
+     * @return bool Whether a join to the translation table is required
+     */
+    protected function addFieldsToQuery(Query $query, array $config)
+    {
+        if ($query->isAutoFieldsEnabled()) {
+            return true;
+        }
+
+        $select = array_filter($query->clause('select'), function ($field) {
+            return is_string($field);
+        });
+
+        if (!$select) {
+            return true;
+        }
+
+        $alias = $config['mainTableAlias'];
+        $joinRequired = false;
+        foreach ($this->translatedFields() as $field) {
+            if (array_intersect($select, [$field, "$alias.$field"])) {
+                $joinRequired = true;
+                $query->select($query->aliasField($field, $config['hasOneAlias']));
+            }
+        }
+
+        if ($joinRequired) {
+            $query->select($query->aliasField('locale', $config['hasOneAlias']));
+        }
+
+        return $joinRequired;
+    }
+
+    /**
+     * Iterate over a clause to alias fields
+     *
+     * The objective here is to transparently prevent ambiguous field errors by
+     * prefixing fields with the appropriate table alias. This method currently
+     * expects to receive an order clause only.
+     *
+     * @param \Cake\ORM\Query $query the query to check
+     * @param string $name The clause name
+     * @param array $config the config to use for adding fields
+     * @return bool Whether a join to the translation table is required
+     */
+    protected function iterateClause(Query $query, $name = '', $config = [])
+    {
+        $clause = $query->clause($name);
+        if (!$clause || !$clause->count()) {
+            return false;
+        }
+
+        $alias = $config['hasOneAlias'];
+        $fields = $this->translatedFields();
+        $mainTableAlias = $config['mainTableAlias'];
+        $mainTableFields = $this->mainFields();
+        $joinRequired = false;
+
+        $clause->iterateParts(function ($c, &$field) use ($fields, $alias, $mainTableAlias, $mainTableFields, &$joinRequired) {
+            if (!is_string($field) || strpos($field, '.')) {
+                return $c;
+            }
+
+            if (in_array($field, $fields)) {
+                $joinRequired = true;
+                $field = "$alias.$field";
+            } elseif (in_array($field, $mainTableFields)) {
+                $field = "$mainTableAlias.$field";
+            }
+
+            return $c;
+        });
+
+        return $joinRequired;
+    }
+
+    /**
+     * Traverse over a clause to alias fields
+     *
+     * The objective here is to transparently prevent ambiguous field errors by
+     * prefixing fields with the appropriate table alias. This method currently
+     * expects to receive a where clause only.
+     *
+     * @param \Cake\ORM\Query $query the query to check
+     * @param string $name The clause name
+     * @param array $config the config to use for adding fields
+     * @return bool Whether a join to the translation table is required
+     */
+    protected function traverseClause(Query $query, $name = '', $config = [])
+    {
+        $clause = $query->clause($name);
+        if (!$clause || !$clause->count()) {
+            return false;
+        }
+
+        $alias = $config['hasOneAlias'];
+        $fields = $this->translatedFields();
+        $mainTableAlias = $config['mainTableAlias'];
+        $mainTableFields = $this->mainFields();
+        $joinRequired = false;
+
+        $clause->traverse(function ($expression) use ($fields, $alias, $mainTableAlias, $mainTableFields, &$joinRequired) {
+            if (!($expression instanceof FieldInterface)) {
+                return;
+            }
+            $field = $expression->getField();
+            if (!is_string($field) || strpos($field, '.')) {
+                return;
+            }
+
+            if (in_array($field, $fields)) {
+                $joinRequired = true;
+                $expression->setField("$alias.$field");
+
+                return;
+            }
+
+            if (in_array($field, $mainTableFields)) {
+                $expression->setField("$mainTableAlias.$field");
+            }
+        });
+
+        return $joinRequired;
+    }
+
+    /**
+     * 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)
+    {
+        $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->translatedFields(), 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();
+        $id = $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 && !$id) {
+            foreach ($this->translatedFields() as $field) {
+                $entity->setDirty($field, true);
+            }
+
+            return;
+        }
+
+        if ($noFields) {
+            return;
+        }
+
+        $where = compact('id', 'locale');
+
+        $translation = $this->translationTable->find()
+            ->select(array_merge(['id', 'locale'], $fields))
+            ->where($where)
+            ->enableBufferedResults(false)
+            ->first();
+
+        if ($translation) {
+            foreach ($fields as $field) {
+                $translation->set($field, $values[$field]);
+            }
+        } else {
+            $translation = $this->translationTable->newEntity(
+                $where + $values,
+                [
+                    'useSetters' => false,
+                    'markNew' => true,
+                ]
+            );
+        }
+
+        $entity->set('_i18n', array_merge($bundled, [$translation]));
+        $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');
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function buildMarshalMap($marshaller, $map, $options)
+    {
+        $this->translatedFields();
+
+        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;
+            },
+        ];
+    }
+
+    /**
+     * Returns a fully aliased field name for translated fields.
+     *
+     * If the requested field is configured as a translation field, 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($field)
+    {
+        if ($this->getLocale() === $this->getConfig('defaultLocale')) {
+            return $this->table->aliasField($field);
+        }
+
+        $translatedFields = $this->translatedFields();
+        if (in_array($field, $translatedFields)) {
+            return $this->getConfig('hasOneAlias') . '.' . $field;
+        }
+
+        return $this->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)
+    {
+        $allowEmpty = $this->_config['allowEmptyTranslations'];
+
+        return $results->map(function ($row) use ($allowEmpty) {
+            if ($row === null) {
+                return $row;
+            }
+
+            $hydrated = !is_array($row);
+
+            if (empty($row['translation'])) {
+                $row['_locale'] = $this->getLocale();
+                unset($row['translation']);
+
+                if ($hydrated) {
+                    $row->clean();
+                }
+
+                return $row;
+            }
+
+            $translation = $row['translation'];
+
+            $keys = $hydrated ? $translation->visibleProperties() : array_keys($translation);
+
+            foreach ($keys as $field) {
+                if ($field === 'locale') {
+                    $row['_locale'] = $translation[$field];
+                    continue;
+                }
+
+                if ($translation[$field] !== null) {
+                    if ($allowEmpty || $translation[$field] !== '') {
+                        $row[$field] = $translation[$field];
+                    }
+                }
+            }
+
+            unset($row['translation']);
+
+            if ($hydrated) {
+                $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) {
+            $translations = (array)$row['_i18n'];
+            if (count($translations) === 0 && $row->get('_translations')) {
+                return $row;
+            }
+
+            $result = [];
+            foreach ($translations as $translation) {
+                unset($translation['id']);
+                $result[$translation['locale']] = $translation;
+            }
+
+            $row['_translations'] = $result;
+            unset($row['_i18n']);
+            if ($row instanceof EntityInterface) {
+                $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;
+        }
+
+        $primaryKey = (array)$this->table->getPrimaryKey();
+        $key = $entity->get(current($primaryKey));
+
+        foreach ($translations as $lang => $translation) {
+            if (!$translation->id) {
+                $update = [
+                    'id' => $key,
+                    'locale' => $lang,
+                ];
+                $translation->set($update, ['guard' => false]);
+            }
+        }
+
+        $entity->set('_i18n', $translations);
+    }
+
+    /**
+     * 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');
+        }
+    }
+
+    /**
+     * Lazy define and return the main table fields
+     *
+     * @return array
+     */
+    protected function mainFields()
+    {
+        $fields = $this->getConfig('mainTableFields');
+
+        if ($fields) {
+            return $fields;
+        }
+
+        $fields = $this->table->getSchema()->columns();
+
+        $this->setConfig('mainTableFields', $fields);
+
+        return $fields;
+    }
+
+    /**
+     * Lazy define and return the translation table fields
+     *
+     * @return array
+     */
+    protected function translatedFields()
+    {
+        $fields = $this->getConfig('fields');
+
+        if ($fields) {
+            return $fields;
+        }
+
+        $table = $this->translationTable;
+        $fields = $table->getSchema()->columns();
+        $fields = array_values(array_diff($fields, ['id', 'locale']));
+
+        $this->setConfig('fields', $fields);
+
+        return $fields;
+    }
+}

+ 55 - 0
tests/Fixture/ArticlesMoreTranslationsFixture.php

@@ -0,0 +1,55 @@
+<?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\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * Class ArticlesTranslationsFixture
+ *
+ */
+class ArticlesMoreTranslationsFixture extends TestFixture
+{
+    /**
+     * fields property
+     *
+     * @var array
+     */
+    public $fields = [
+        'id' => ['type' => 'integer'],
+        'locale' => ['type' => 'string', 'null' => false],
+        'title' => ['type' => 'string', 'null' => false],
+        'subtitle' => ['type' => 'string', 'null' => false],
+        'body' => 'text',
+        '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id', 'locale']]],
+    ];
+
+    /**
+     * records property
+     *
+     * @var array
+     */
+    public $records = [
+        ['locale' => 'eng', 'id' => 1, 'title' => 'Title #1', 'subtitle' => 'SubTitle #1', 'body' => 'Content #1'],
+        ['locale' => 'deu', 'id' => 1, 'title' => 'Titel #1', 'subtitle' => 'SubTitel #1', 'body' => 'Inhalt #1'],
+        ['locale' => 'cze', 'id' => 1, 'title' => 'Titulek #1', 'subtitle' => 'SubTitulek #1', 'body' => 'Obsah #1'],
+        ['locale' => 'eng', 'id' => 2, 'title' => 'Title #2', 'subtitle' => 'SubTitle #2', 'body' => 'Content #2'],
+        ['locale' => 'deu', 'id' => 2, 'title' => 'Titel #2', 'subtitle' => 'SubTitel #2', 'body' => 'Inhalt #2'],
+        ['locale' => 'cze', 'id' => 2, 'title' => 'Titulek #2', 'subtitle' => 'SubTitulek #2', 'body' => 'Obsah #2'],
+        ['locale' => 'eng', 'id' => 3, 'title' => 'Title #3', 'subtitle' => 'SubTitle #3', 'body' => 'Content #3'],
+        ['locale' => 'deu', 'id' => 3, 'title' => 'Titel #3', 'subtitle' => 'SubTitel #3', 'body' => 'Inhalt #3'],
+        ['locale' => 'cze', 'id' => 3, 'title' => 'Titulek #3', 'subtitle' => 'SubTitulek #3', 'body' => 'Obsah #3'],
+    ];
+}

+ 56 - 0
tests/Fixture/ArticlesTranslationsFixture.php

@@ -0,0 +1,56 @@
+<?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\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * Class ArticlesTranslationsFixture
+ *
+ */
+class ArticlesTranslationsFixture extends TestFixture
+{
+    /**
+     * fields property
+     *
+     * @var array
+     */
+    public $fields = [
+        'id' => ['type' => 'integer'],
+        'locale' => ['type' => 'string', 'null' => false],
+        'title' => ['type' => 'string', 'null' => true],
+        'body' => 'text',
+        '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id', 'locale']]],
+    ];
+
+    /**
+     * records property
+     *
+     * @var array
+     */
+    public $records = [
+        ['locale' => 'eng', 'id' => 1, 'title' => 'Title #1', 'body' => 'Content #1'],
+        ['locale' => 'deu', 'id' => 1, 'title' => 'Titel #1', 'body' => 'Inhalt #1'],
+        ['locale' => 'cze', 'id' => 1, 'title' => 'Titulek #1', 'body' => 'Obsah #1'],
+        ['locale' => 'spa', 'id' => 1, 'title' => 'First Article', 'body' => 'Contenido #1'],
+        ['locale' => 'zzz', 'id' => 1, 'title' => '', 'body' => ''],
+        ['locale' => 'eng', 'id' => 2, 'title' => 'Title #2', 'body' => 'Content #2'],
+        ['locale' => 'deu', 'id' => 2, 'title' => 'Titel #2', 'body' => 'Inhalt #2'],
+        ['locale' => 'cze', 'id' => 2, 'title' => 'Titulek #2', 'body' => 'Obsah #2'],
+        ['locale' => 'eng', 'id' => 3, 'title' => 'Title #3', 'body' => 'Content #3'],
+        ['locale' => 'deu', 'id' => 3, 'title' => 'Titel #3', 'body' => 'Inhalt #3'],
+        ['locale' => 'cze', 'id' => 3, 'title' => 'Titulek #3', 'body' => 'Obsah #3'],
+    ];
+}

+ 45 - 0
tests/Fixture/AuthorsTranslationsFixture.php

@@ -0,0 +1,45 @@
+<?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\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * Class AuthorsTranslationsFixture
+ *
+ */
+class AuthorsTranslationsFixture extends TestFixture
+{
+    /**
+     * fields property
+     *
+     * @var array
+     */
+    public $fields = [
+        'id' => ['type' => 'integer'],
+        'locale' => ['type' => 'string', 'null' => false],
+        'name' => ['type' => 'string', 'null' => false],
+        '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id', 'locale']]],
+    ];
+
+    /**
+     * records property
+     *
+     * @var array
+     */
+    public $records = [
+        ['locale' => 'eng', 'id' => 1, 'name' => 'May-rianoh'],
+    ];
+}

+ 49 - 0
tests/Fixture/CommentsTranslationsFixture.php

@@ -0,0 +1,49 @@
+<?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\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * Class CommentsTranslationsFixture
+ *
+ */
+class CommentsTranslationsFixture extends TestFixture
+{
+    /**
+     * fields property
+     *
+     * @var array
+     */
+    public $fields = [
+        'id' => ['type' => 'integer'],
+        'locale' => ['type' => 'string', 'null' => false],
+        'comment' => 'text',
+        '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id', 'locale']]],
+    ];
+
+    /**
+     * records property
+     *
+     * @var array
+     */
+    public $records = [
+        ['locale' => 'eng', 'id' => 1, 'comment' => 'Comment #1'],
+        ['locale' => 'eng', 'id' => 2, 'comment' => 'Comment #2'],
+        ['locale' => 'eng', 'id' => 3, 'comment' => 'Comment #3'],
+        ['locale' => 'eng', 'id' => 4, 'comment' => 'Comment #4'],
+        ['locale' => 'spa', 'id' => 4, 'comment' => 'Comentario #4'],
+    ];
+}

+ 43 - 0
tests/Fixture/GroupsTranslationsFixture.php

@@ -0,0 +1,43 @@
+<?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\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * Class GroupsTranslationsFixture
+ *
+ */
+class GroupsTranslationsFixture extends TestFixture
+{
+    /**
+     * fields property
+     *
+     * @var array
+     */
+    public $fields = [
+        'id' => ['type' => 'integer'],
+        'locale' => ['type' => 'string', 'null' => false],
+        'title' => ['type' => 'string'],
+        '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id', 'locale']]],
+    ];
+
+    /**
+     * records property
+     *
+     * @var array
+     */
+    public $records = [];
+}

+ 45 - 0
tests/Fixture/SpecialTagsTranslationsFixture.php

@@ -0,0 +1,45 @@
+<?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\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * Class SpecialTagsTranslationsFixture
+ */
+class SpecialTagsTranslationsFixture extends TestFixture
+{
+
+    /**
+     * fields property
+     *
+     * @var array
+     */
+    public $fields = [
+        'id' => ['type' => 'integer'],
+        'locale' => ['type' => 'string', 'null' => false],
+        'extra_info' => ['type' => 'string'],
+        '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id', 'locale']]],
+    ];
+
+    /**
+     * records property
+     *
+     * @var array
+     */
+    public $records = [
+        ['id' => 2, 'locale' => 'eng', 'extra_info' => 'Translated Info'],
+    ];
+}

+ 53 - 0
tests/Fixture/TagsShadowTranslationsFixture.php

@@ -0,0 +1,53 @@
+<?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\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * Class TagsShadowTranslationsFixture
+ *
+ */
+class TagsShadowTranslationsFixture extends TestFixture
+{
+    /**
+     * fields property
+     *
+     * @var array
+     */
+    public $fields = [
+        'id' => ['type' => 'integer'],
+        'locale' => ['type' => 'string', 'null' => false],
+        'name' => ['type' => 'string', 'null' => false],
+        '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id', 'locale']]],
+    ];
+
+    /**
+     * records property
+     *
+     * @var array
+     */
+    public $records = [
+        ['locale' => 'eng', 'id' => 1, 'name' => 'tag1 in eng'],
+        ['locale' => 'deu', 'id' => 1, 'name' => 'tag1 in deu'],
+        ['locale' => 'cze', 'id' => 1, 'name' => 'tag1 in cze'],
+        ['locale' => 'eng', 'id' => 2, 'name' => 'tag2 in eng'],
+        ['locale' => 'deu', 'id' => 2, 'name' => 'tag2 in deu'],
+        ['locale' => 'cze', 'id' => 2, 'name' => 'tag2 in cze'],
+        ['locale' => 'eng', 'id' => 3, 'name' => 'tag3 in eng'],
+        ['locale' => 'deu', 'id' => 3, 'name' => 'tag3 in deu'],
+        ['locale' => 'cze', 'id' => 3, 'name' => 'tag3 in cze'],
+    ];
+}

File diff suppressed because it is too large
+ 1074 - 0
tests/TestCase/ORM/Behavior/TranslateBehaviorShadowTableTest.php