Browse Source

Merge pull request #9225 from cakephp/behavior-marshalling

RFC - Enable Behaviors to participate in marshalling
José Lorenzo Rodríguez 9 years ago
parent
commit
fb8109f81a

+ 45 - 2
src/ORM/Behavior/TranslateBehavior.php

@@ -22,6 +22,7 @@ use Cake\I18n\I18n;
 use Cake\ORM\Behavior;
 use Cake\ORM\Entity;
 use Cake\ORM\Locator\LocatorAwareTrait;
+use Cake\ORM\PropertyMarshalInterface;
 use Cake\ORM\Query;
 use Cake\ORM\Table;
 use Cake\Utility\Inflector;
@@ -38,7 +39,7 @@ use Cake\Utility\Inflector;
  * 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 TranslateBehavior extends Behavior
+class TranslateBehavior extends Behavior implements PropertyMarshalInterface
 {
 
     use LocatorAwareTrait;
@@ -82,7 +83,8 @@ class TranslateBehavior extends Behavior
         'allowEmptyTranslations' => true,
         'onlyTranslated' => false,
         'strategy' => 'subquery',
-        'tableLocator' => null
+        'tableLocator' => null,
+        'validator' => false
     ];
 
     /**
@@ -327,6 +329,47 @@ class TranslateBehavior extends Behavior
     }
 
     /**
+     * Add in _translations marshalling handlers if translation marshalling is
+     * enabled. You need to specifically enable translation marshalling by adding
+     * `'translations' => true` to the options provided to `Table::newEntity()` or `Table::patchEntity()`.
+     *
+     * {@inheritDoc}
+     */
+    public function buildMarhshalMap($marshaller, $map, $options)
+    {
+        if (isset($options['translations']) && !$options['translations']) {
+            return [];
+        }
+
+        return [
+            '_translations' => function ($value, $entity) use ($marshaller, $options) {
+                $translations = $entity->get('_translations');
+                foreach ($this->_config['fields'] as $field) {
+                    $options['validate'] = $this->_config['validator'];
+                    $errors = [];
+                    if (!is_array($value)) {
+                        return;
+                    }
+                    foreach ($value as $language => $fields) {
+                        if (!isset($translations[$language])) {
+                            $translations[$language] = $this->_table->newEntity();
+                        }
+                        $marshaller->merge($translations[$language], $fields, $options);
+                        if ((bool)$translations[$language]->errors()) {
+                            $errors[$language] = $translations[$language]->errors();
+                        }
+                    }
+                    // Set errors into the root entity, so validation errors
+                    // match the original form data position.
+                    $entity->errors($errors);
+                }
+
+                return $translations;
+            }
+        ];
+    }
+
+    /**
      * Sets all future finds for the bound table to also fetch translated fields for
      * the passed locale. If no value is passed, it returns the currently configured
      * locale

+ 70 - 49
src/ORM/Marshaller.php

@@ -20,6 +20,7 @@ use Cake\Database\Expression\TupleComparison;
 use Cake\Database\Type;
 use Cake\Datasource\EntityInterface;
 use Cake\Datasource\InvalidPropertyInterface;
+use Cake\ORM\PropertyMarshalInterface;
 use RuntimeException;
 
 /**
@@ -55,39 +56,73 @@ class Marshaller
     }
 
     /**
-     * Build the map of property => association names.
+     * Build the map of property => marshalling callable.
      *
+     * @param array $data The data being marshalled.
      * @param array $options List of options containing the 'associated' key.
      * @throws \InvalidArgumentException When associations do not exist.
      * @return array
      */
-    protected function _buildPropertyMap($options)
+    protected function _buildPropertyMap($data, $options)
     {
-        if (empty($options['associated'])) {
-            return [];
+        $map = [];
+        $schema = $this->_table->schema();
+
+        // Is a concrete column?
+        foreach (array_keys($data) as $prop) {
+            $columnType = $schema->columnType($prop);
+            if ($columnType) {
+                $map[$prop] = function ($value, $entity) use ($columnType) {
+                    return Type::build($columnType)->marshal($value);
+                };
+            }
         }
 
-        $include = $options['associated'];
-        $map = [];
-        $include = $this->_normalizeAssociations($include);
+        // Map associations
+        $options += ['associated' => []];
+        $include = $this->_normalizeAssociations($options['associated']);
         foreach ($include as $key => $nested) {
             if (is_int($key) && is_scalar($nested)) {
                 $key = $nested;
                 $nested = [];
             }
             $assoc = $this->_table->association($key);
-            if ($assoc) {
-                $map[$assoc->property()] = ['association' => $assoc] + $nested + ['associated' => []];
-                continue;
-            }
             // If the key is not a special field like _ids or _joinData
             // it is a missing association that we should error on.
-            if (substr($key, 0, 1) !== "_") {
-                throw new \InvalidArgumentException(sprintf(
-                    'Cannot marshal data for "%s" association. It is not associated with "%s".',
-                    $key,
-                    $this->_table->alias()
-                ));
+            if (!$assoc) {
+                if (substr($key, 0, 1) !== '_') {
+                    throw new \InvalidArgumentException(sprintf(
+                        'Cannot marshal data for "%s" association. It is not associated with "%s".',
+                        $key,
+                        $this->_table->alias()
+                    ));
+                }
+                continue;
+            }
+            if (isset($options['forceNew'])) {
+                $nested['forceNew'] = $options['forceNew'];
+            }
+            if (isset($options['isMerge'])) {
+                $callback = function ($value, $entity) use ($assoc, $nested) {
+                    $options = $nested + ['associated' => []];
+
+                    return $this->_mergeAssociation($entity->get($assoc->property()), $assoc, $value, $options);
+                };
+            } else {
+                $callback = function ($value, $entity) use ($assoc, $nested) {
+                    $options = $nested + ['associated' => []];
+
+                    return $this->_marshalAssociation($assoc, $value, $options);
+                };
+            }
+            $map[$assoc->property()] = $callback;
+        }
+
+        $behaviors = $this->_table->behaviors();
+        foreach ($behaviors->loaded() as $name) {
+            $behavior = $behaviors->get($name);
+            if ($behavior instanceof PropertyMarshalInterface) {
+                $map = $behavior->buildMarhshalMap($this, $map, $options);
             }
         }
 
@@ -128,9 +163,6 @@ class Marshaller
     {
         list($data, $options) = $this->_prepareDataAndOptions($data, $options);
 
-        $propertyMap = $this->_buildPropertyMap($options);
-
-        $schema = $this->_table->schema();
         $primaryKey = (array)$this->_table->primaryKey();
         $entityClass = $this->_table->entityClass();
         $entity = new $entityClass();
@@ -141,13 +173,10 @@ class Marshaller
                 $entity->accessible($key, $value);
             }
         }
-
-        $marshallOptions = [];
-        if (isset($options['forceNew'])) {
-            $marshallOptions['forceNew'] = $options['forceNew'];
-        }
-
         $errors = $this->_validate($data, $options, true);
+
+        $options['isMerge'] = false;
+        $propertyMap = $this->_buildPropertyMap($data, $options);
         $properties = [];
         foreach ($data as $key => $value) {
             if (!empty($errors[$key])) {
@@ -156,18 +185,15 @@ class Marshaller
                 }
                 continue;
             }
-            $columnType = $schema->columnType($key);
-            if (isset($propertyMap[$key])) {
-                $assoc = $propertyMap[$key]['association'];
-                $value = $this->_marshalAssociation($assoc, $value, $propertyMap[$key] + $marshallOptions);
-            } elseif ($value === '' && in_array($key, $primaryKey, true)) {
+
+            if ($value === '' && in_array($key, $primaryKey, true)) {
                 // Skip marshalling '' for pk fields.
                 continue;
-            } elseif ($columnType) {
-                $converter = Type::build($columnType);
-                $value = $converter->marshal($value);
+            } elseif (isset($propertyMap[$key])) {
+                $properties[$key] = $propertyMap[$key]($value, $entity);
+            } else {
+                $properties[$key] = $value;
             }
-            $properties[$key] = $value;
         }
 
         if (!isset($options['fieldList'])) {
@@ -489,7 +515,6 @@ class Marshaller
     {
         list($data, $options) = $this->_prepareDataAndOptions($data, $options);
 
-        $propertyMap = $this->_buildPropertyMap($options);
         $isNew = $entity->isNew();
         $keys = [];
 
@@ -505,6 +530,8 @@ class Marshaller
 
         $errors = $this->_validate($data + $keys, $options, $isNew);
         $schema = $this->_table->schema();
+        $options['isMerge'] = true;
+        $propertyMap = $this->_buildPropertyMap($data, $options);
         $properties = $marshalledAssocs = [];
         foreach ($data as $key => $value) {
             if (!empty($errors[$key])) {
@@ -513,17 +540,12 @@ class Marshaller
                 }
                 continue;
             }
-
-            $columnType = $schema->columnType($key);
             $original = $entity->get($key);
 
             if (isset($propertyMap[$key])) {
-                $assoc = $propertyMap[$key]['association'];
-                $value = $this->_mergeAssociation($original, $assoc, $value, $propertyMap[$key]);
-                $marshalledAssocs[$key] = true;
-            } elseif ($columnType) {
-                $converter = Type::build($columnType);
-                $value = $converter->marshal($value);
+                $value = $propertyMap[$key]($value, $entity);
+
+                // Don't dirty complex objects that were objects before.
                 $isObject = is_object($value);
                 if ((!$isObject && $original === $value) ||
                     ($isObject && $original == $value)
@@ -531,7 +553,6 @@ class Marshaller
                     continue;
                 }
             }
-
             $properties[$key] = $value;
         }
 
@@ -539,9 +560,9 @@ class Marshaller
             $entity->set($properties);
             $entity->errors($errors);
 
-            foreach (array_keys($marshalledAssocs) as $field) {
-                if ($properties[$field] instanceof EntityInterface) {
-                    $entity->dirty($field, $properties[$field]->dirty());
+            foreach ($properties as $field => $value) {
+                if ($value instanceof EntityInterface) {
+                    $entity->dirty($field, $value->dirty());
                 }
             }
 
@@ -551,7 +572,7 @@ class Marshaller
         foreach ((array)$options['fieldList'] as $field) {
             if (array_key_exists($field, $properties)) {
                 $entity->set($field, $properties[$field]);
-                if ($properties[$field] instanceof EntityInterface && isset($marshalledAssocs[$field])) {
+                if ($properties[$field] instanceof EntityInterface) {
                     $entity->dirty($field, $properties[$field]->dirty());
                 }
             }

+ 34 - 0
src/ORM/PropertyMarshalInterface.php

@@ -0,0 +1,34 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://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. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         3.4.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\ORM;
+
+/**
+ * Behaviors implementing this interface can participate in entity marshalling.
+ *
+ * This enables behaviors to define behavior for how the properties they provide/manage
+ * should be marshalled.
+ */
+interface PropertyMarshalInterface
+{
+    /**
+     * Build a set of properties that should be included in the marshalling process.
+     *
+     * @param \Cake\ORM\Marhshaller $marshaller The marhshaller of the table the behavior is attached to.
+     * @param array $map The property map being built.
+     * @param array $options The options array used in the marshalling call.
+     * @return array A map of `[property => callable]` of additional properties to marshal.
+     */
+    public function buildMarhshalMap($marshaller, $map, $options);
+}

+ 308 - 0
tests/TestCase/ORM/Behavior/TranslateBehaviorTest.php

@@ -20,6 +20,7 @@ use Cake\ORM\Behavior\Translate\TranslateTrait;
 use Cake\ORM\Entity;
 use Cake\ORM\TableRegistry;
 use Cake\TestSuite\TestCase;
+use Cake\Validation\Validator;
 
 /**
  * Stub entity class
@@ -1092,4 +1093,311 @@ class TranslateBehaviorTest extends TestCase
         $this->assertEquals('New Body', $result->body);
         $this->assertSame($article->title, $result->title);
     }
+
+    /**
+     * Test save new entity with _translations field
+     *
+     * @return void
+     */
+    public function testSaveNewRecordWithTranslatesField()
+    {
+        $table = TableRegistry::get('Articles');
+        $table->addBehavior('Translate', [
+            'fields' => ['title'],
+            'validator' => (new \Cake\Validation\Validator)->add('title', 'notBlank', ['rule' => 'notBlank'])
+        ]);
+        $table->entityClass(__NAMESPACE__ . '\Article');
+
+        $data = [
+            'author_id' => 1,
+            'published' => 'N',
+            '_translations' => [
+                'en' => [
+                    'title' => 'Title EN',
+                    'body' => 'Body EN'
+                ],
+                'es' => [
+                    'title' => 'Title ES'
+                ]
+            ]
+        ];
+
+        $article = $table->patchEntity($table->newEntity(), $data);
+        $result = $table->save($article);
+
+        $this->assertNotFalse($result);
+
+        $expected = [
+            [
+                'en' => [
+                    'title' => 'Title EN',
+                    'locale' => 'en'
+                ],
+                'es' => [
+                    'title' => 'Title ES',
+                    'locale' => 'es'
+                ]
+            ]
+        ];
+        $result = $table->find('translations')->where(['id' => $result->id]);
+        $this->assertEquals($expected, $this->_extractTranslations($result)->toArray());
+    }
+
+    /**
+     * Test update entity with _translations field.
+     *
+     * @return void
+     */
+    public function testSaveExistingRecordWithTranslatesField()
+    {
+        $table = TableRegistry::get('Articles');
+        $table->addBehavior('Translate', ['fields' => ['title', 'body']]);
+        $table->entityClass(__NAMESPACE__ . '\Article');
+
+        $data = [
+            'author_id' => 1,
+            'published' => 'Y',
+            '_translations' => [
+                'eng' => [
+                    'title' => 'First Article1',
+                    'body' => 'First Article content has been updated'
+                ],
+                'spa' => [
+                    'title' => 'Mi nuevo titulo',
+                    'body' => 'Contenido Actualizado'
+                ]
+            ]
+        ];
+
+        $article = $table->find()->first();
+        $article = $table->patchEntity($article, $data);
+
+        $this->assertNotFalse($table->save($article));
+
+        $results = $this->_extractTranslations(
+            $table->find('translations')->where(['id' => 1])
+        )->first();
+
+        $this->assertEquals('Mi nuevo titulo', $results['spa']['title']);
+        $this->assertEquals('Contenido Actualizado', $results['spa']['body']);
+
+        $this->assertEquals('First Article1', $results['eng']['title']);
+        $this->assertEquals('Description #1', $results['eng']['description']);
+    }
+
+    /**
+     * Test that no properties are enabled when the translations
+     * option is off.
+     *
+     * @return void
+     */
+    public function testBuildMarshalMapTranslationsOff()
+    {
+        $table = TableRegistry::get('Articles');
+        $table->addBehavior('Translate', ['fields' => ['title', 'body']]);
+
+        $marshaller = $table->marshaller();
+        $translate = $table->behaviors()->get('Translate');
+        $result = $translate->buildMarhshalMap($marshaller, [], ['translations' => false]);
+        $this->assertSame([], $result);
+    }
+
+    /**
+     * Test building a marshal map with translations on.
+     *
+     * @return void
+     */
+    public function testBuildMarshalMapTranslationsOn()
+    {
+        $table = TableRegistry::get('Articles');
+        $table->addBehavior('Translate', ['fields' => ['title', 'body']]);
+        $marshaller = $table->marshaller();
+        $translate = $table->behaviors()->get('Translate');
+
+        $result = $translate->buildMarhshalMap($marshaller, [], ['translations' => true]);
+        $this->assertArrayHasKey('_translations', $result);
+        $this->assertInstanceOf('Closure', $result['_translations']);
+
+        $result = $translate->buildMarhshalMap($marshaller, [], []);
+        $this->assertArrayHasKey('_translations', $result);
+        $this->assertInstanceOf('Closure', $result['_translations']);
+    }
+
+    /**
+     * Test marshalling non-array data
+     *
+     * @return void
+     */
+    public function testBuildMarshalMapNonArrayData()
+    {
+        $table = TableRegistry::get('Articles');
+        $table->addBehavior('Translate', ['fields' => ['title', 'body']]);
+        $translate = $table->behaviors()->get('Translate');
+
+        $map = $translate->buildMarhshalMap($table->marshaller(), [], []);
+        $entity = $table->newEntity();
+        $result = $map['_translations']('garbage', $entity);
+        $this->assertNull($result, 'Non-array should not error out.');
+        $this->assertEmpty($entity->errors());
+        $this->assertEmpty($entity->get('_translations'));
+    }
+
+    /**
+     * Test buildMarshalMap() builds new entities.
+     *
+     * @return void
+     */
+    public function testBuildMarshalMapBuildEntities()
+    {
+        $table = TableRegistry::get('Articles');
+        $table->addBehavior('Translate', ['fields' => ['title', 'body']]);
+        $translate = $table->behaviors()->get('Translate');
+
+        $map = $translate->buildMarhshalMap($table->marshaller(), [], []);
+        $entity = $table->newEntity();
+        $data = [
+            'en' => [
+                'title' => 'English Title',
+                'body' => 'English Content'
+            ],
+            'es' => [
+                'title' => 'Titulo Español',
+                'body' => 'Contenido Español'
+            ]
+        ];
+        $result = $map['_translations']($data, $entity);
+        $this->assertEmpty($entity->errors(), 'No validation errors.');
+        $this->assertCount(2, $result);
+        $this->assertArrayHasKey('en', $result);
+        $this->assertArrayHasKey('es', $result);
+        $this->assertEquals('English Title', $result['en']->title);
+        $this->assertEquals('Titulo Español', $result['es']->title);
+    }
+
+    /**
+     * Test that validation errors are added to the original entity.
+     *
+     * @return void
+     */
+    public function testBuildMarshalMapBuildEntitiesValidationErrors()
+    {
+        $table = TableRegistry::get('Articles');
+        $table->addBehavior('Translate', [
+            'fields' => ['title', 'body'],
+            'validator' => 'custom'
+        ]);
+        $validator = (new Validator)->add('title', 'notBlank', ['rule' => 'notBlank']);
+        $table->validator('custom', $validator);
+        $translate = $table->behaviors()->get('Translate');
+
+        $entity = $table->newEntity();
+        $map = $translate->buildMarhshalMap($table->marshaller(), [], []);
+        $data = [
+            'en' => [
+                'title' => 'English Title',
+                'body' => 'English Content'
+            ],
+            'es' => [
+                'title' => '',
+                'body' => 'Contenido Español'
+            ]
+        ];
+        $result = $map['_translations']($data, $entity);
+        $this->assertNotEmpty($entity->errors(), 'Needs validation errors.');
+        $expected = [
+            'title' => [
+                '_empty' => 'This field cannot be left empty'
+            ]
+        ];
+        $this->assertEquals($expected, $entity->errors('es'));
+
+        $this->assertEquals('English Title', $result['en']->title);
+        $this->assertEquals('', $result['es']->title);
+    }
+
+    /**
+     * Test that marshalling updates existing translation entities.
+     *
+     * @return void
+     */
+    public function testBuildMarshalMapUpdateExistingEntities()
+    {
+        $table = TableRegistry::get('Articles');
+        $table->addBehavior('Translate', [
+            'fields' => ['title', 'body'],
+        ]);
+        $translate = $table->behaviors()->get('Translate');
+
+        $entity = $table->newEntity();
+        $es = $table->newEntity(['title' => 'Old title', 'body' => 'Old body']);
+        $en = $table->newEntity(['title' => 'Old title', 'body' => 'Old body']);
+        $entity->set('_translations', [
+            'es' => $es,
+            'en' => $en,
+        ]);
+        $map = $translate->buildMarhshalMap($table->marshaller(), [], []);
+        $data = [
+            'en' => [
+                'title' => 'English Title',
+            ],
+            'es' => [
+                'title' => 'Spanish Title',
+            ]
+        ];
+        $result = $map['_translations']($data, $entity);
+        $this->assertEmpty($entity->errors(), 'No validation errors.');
+        $this->assertSame($en, $result['en']);
+        $this->assertSame($es, $result['es']);
+        $this->assertSame($en, $entity->get('_translations')['en']);
+        $this->assertSame($es, $entity->get('_translations')['es']);
+
+        $this->assertEquals('English Title', $result['en']->title);
+        $this->assertEquals('Spanish Title', $result['es']->title);
+        $this->assertEquals('Old body', $result['en']->body);
+        $this->assertEquals('Old body', $result['es']->body);
+    }
+
+    /**
+     * Test that updating translation records works with validations.
+     *
+     * @return void
+     */
+    public function testBuildMarshalMapUpdateEntitiesValidationErrors()
+    {
+        $table = TableRegistry::get('Articles');
+        $table->addBehavior('Translate', [
+            'fields' => ['title', 'body'],
+            'validator' => 'custom'
+        ]);
+        $validator = (new Validator)->add('title', 'notBlank', ['rule' => 'notBlank']);
+        $table->validator('custom', $validator);
+        $translate = $table->behaviors()->get('Translate');
+
+        $entity = $table->newEntity();
+        $es = $table->newEntity(['title' => 'Old title', 'body' => 'Old body']);
+        $en = $table->newEntity(['title' => 'Old title', 'body' => 'Old body']);
+        $entity->set('_translations', [
+            'es' => $es,
+            'en' => $en,
+        ]);
+        $map = $translate->buildMarhshalMap($table->marshaller(), [], []);
+        $data = [
+            'en' => [
+                'title' => 'English Title',
+                'body' => 'English Content'
+            ],
+            'es' => [
+                'title' => '',
+                'body' => 'Contenido Español'
+            ]
+        ];
+        $result = $map['_translations']($data, $entity);
+        $this->assertNotEmpty($entity->errors(), 'Needs validation errors.');
+        $expected = [
+            'title' => [
+                '_empty' => 'This field cannot be left empty'
+            ]
+        ];
+        $this->assertEquals($expected, $entity->errors('es'));
+    }
 }

+ 88 - 0
tests/TestCase/ORM/MarshallerTest.php

@@ -2370,6 +2370,42 @@ class MarshallerTest extends TestCase
     }
 
     /**
+     * Test one() with translations
+     *
+     * @return void
+     */
+    public function testOneWithTranslations()
+    {
+        $this->articles->addBehavior('Translate', [
+            'fields' => ['title', 'body']
+        ]);
+
+        $data = [
+            'author_id' => 1,
+            '_translations' => [
+                'en' => [
+                    'title' => 'English Title',
+                    'body' => 'English Content'
+                ],
+                'es' => [
+                    'title' => 'Titulo Español',
+                    'body' => 'Contenido Español'
+                ]
+            ]
+        ];
+
+        $marshall = new Marshaller($this->articles);
+        $result = $marshall->one($data, []);
+        $this->assertEmpty($result->errors());
+
+        $translations = $result->get('_translations');
+        $this->assertCount(2, $translations);
+        $this->assertInstanceOf(__NAMESPACE__ . '\OpenEntity', $translations['en']);
+        $this->assertInstanceOf(__NAMESPACE__ . '\OpenEntity', $translations['es']);
+        $this->assertEquals($data['_translations']['en'], $translations['en']->toArray());
+    }
+
+    /**
      * Tests that it is possible to pass a fieldList option to the merge method
      *
      * @return void
@@ -2884,6 +2920,46 @@ class MarshallerTest extends TestCase
     }
 
     /**
+     * Test merge() with translate behavior integration
+     *
+     * @return void
+     */
+    public function testMergeWithTranslations()
+    {
+        $this->articles->addBehavior('Translate', [
+            'fields' => ['title', 'body']
+        ]);
+
+        $data = [
+            'author_id' => 1,
+            '_translations' => [
+                'en' => [
+                    'title' => 'English Title',
+                    'body' => 'English Content'
+                ],
+                'es' => [
+                    'title' => 'Titulo Español',
+                    'body' => 'Contenido Español'
+                ]
+            ]
+        ];
+
+        $marshall = new Marshaller($this->articles);
+        $entity = $this->articles->newEntity();
+        $result = $marshall->merge($entity, $data, []);
+
+        $this->assertSame($entity, $result);
+        $this->assertEmpty($result->errors());
+        $this->assertTrue($result->dirty('_translations'));
+
+        $translations = $result->get('_translations');
+        $this->assertCount(2, $translations);
+        $this->assertInstanceOf(__NAMESPACE__ . '\OpenEntity', $translations['en']);
+        $this->assertInstanceOf(__NAMESPACE__ . '\OpenEntity', $translations['es']);
+        $this->assertEquals($data['_translations']['en'], $translations['en']->toArray());
+    }
+
+    /**
      * Test Model.beforeMarshal event.
      *
      * @return void
@@ -3006,6 +3082,12 @@ class MarshallerTest extends TestCase
         $this->assertTrue($entity->user->isNew());
     }
 
+    /**
+     * Test that primary key meta data is being read from the table
+     * and not the schema reflection when handling belongsToMany associations.
+     *
+     * @return void
+     */
     public function testEnsurePrimaryKeyBeingReadFromTableForHandlingEmptyStringPrimaryKey()
     {
         $data = [
@@ -3023,6 +3105,12 @@ class MarshallerTest extends TestCase
         $this->assertNull($result->id);
     }
 
+    /**
+     * Test that primary key meta data is being read from the table
+     * and not the schema reflection when handling belongsToMany associations.
+     *
+     * @return void
+     */
     public function testEnsurePrimaryKeyBeingReadFromTableWhenLoadingBelongsToManyRecordsByPrimaryKey()
     {
         $data = [