Browse Source

Merge pull request #2935 from cakephp/3.0-marshaller-merge

3.0 marshaller merge
Mark Story 12 years ago
parent
commit
918dbfcd12

+ 39 - 0
src/Datasource/RepositoryInterface.php

@@ -169,4 +169,43 @@ interface RepositoryInterface {
  */
 	public function newEntities(array $data, $associations = null);
 
+/**
+ * Merges the passed `$data` into `$entity` respecting the accessible
+ * fields configured on the entity. Returns the same entity after being
+ * altered.
+ *
+ * This is most useful when editing an existing entity using request data:
+ *
+ * {{{
+ * $article = $this->Articles->patchEntity($article, $this->request->data());
+ * }}}
+ *
+ * @param \Cake\Datasource\EntityInterface $entity the entity that will get the
+ * data merged in
+ * @param array $data key value list of fields to be merged into the entity
+ * @param array $include The list of associations to be merged
+ * @return \Cake\Datasource\EntityInterface
+ */
+	public function patchEntity(EntityInterface $entity, array $data, $associations = null);
+
+/**
+ * Merges each of the elements passed in `$data` into the entities
+ * found in `$entities` respecting the accessible fields configured on the entities.
+ * Merging is done by matching the primary key in each of the elements in `$data`
+ * and `$entities`.
+ *
+ * This is most useful when editing a list of existing entities using request data:
+ *
+ * {{{
+ * $article = $this->Articles->patchEntities($articles, $this->request->data());
+ * }}}
+ *
+ * @param array|\Traversable $entities the entities that will get the
+ * data merged in
+ * @param array $data list of arrays to be merged into the entities
+ * @param array $include The list of associations to be merged
+ * @return array
+ */
+	public function patchEntities($entities, array $data, $associations = null);
+
 }

+ 172 - 1
src/ORM/Marshaller.php

@@ -14,8 +14,10 @@
  */
 namespace Cake\ORM;
 
+use Cake\Collection\Collection;
 use Cake\Database\Expression\TupleComparison;
 use Cake\Database\Type;
+use Cake\Datasource\EntityInterface;
 use Cake\ORM\Association;
 use Cake\ORM\Table;
 
@@ -26,6 +28,8 @@ use Cake\ORM\Table;
  *
  * @see \Cake\ORM\Table::newEntity()
  * @see \Cake\ORM\Table::newEntities()
+ * @see \Cake\ORM\Table::patchEntity()
+ * @see \Cake\ORM\Table::patchEntities()
  */
 class Marshaller {
 
@@ -47,7 +51,7 @@ class Marshaller {
  * Constructor.
  *
  * @param \Cake\ORM\Table $table
- * @param boolean Whether or not this masrhaller is in safe mode
+ * @param boolean Whether or not this marshaller is in safe mode
  */
 	public function __construct(Table $table, $safe = false) {
 		$this->_table = $table;
@@ -214,4 +218,171 @@ class Marshaller {
 		return $assoc->find()->where($filter)->toArray();
 	}
 
+/**
+ * Merges `$data` into `$entity` and recursively does the same for each one of
+ * the association names passed in `$include`. When merging associations, if an
+ * entity is not present in the parent entity for a given association, a new one
+ * will be created.
+ *
+ * When merging HasMany or BelongsToMany associations, all the entities in the
+ * `$data` array will appear, those that can be matched by primary key will get
+ * the data merged, but those that cannot, will be discarded.
+ *
+ * @param \Cake\Datasource\EntityInterface $entity the entity that will get the
+ * data merged in
+ * @param array $data key value list of fields to be merged into the entity
+ * @param array $include The list of associations to be merged
+ * @return \Cake\Datasource\EntityInterface
+ */
+	public function merge(EntityInterface $entity, array $data, $include = []) {
+		$propertyMap = $this->_buildPropertyMap($include);
+		$tableName = $this->_table->alias();
+
+		if (isset($data[$tableName])) {
+			$data = $data[$tableName];
+		}
+
+		$properties = [];
+		foreach ($data as $key => $value) {
+			$original = $entity->get($key);
+			if (isset($propertyMap[$key])) {
+				$assoc = $propertyMap[$key]['association'];
+				$nested = $propertyMap[$key]['nested'];
+				$value = $this->_mergeAssociation($original, $assoc, $value, $nested);
+			} elseif ($original == $value) {
+				continue;
+			}
+			$properties[$key] = $value;
+		}
+
+		$entity->set($properties);
+		return $entity;
+	}
+
+/**
+ * Merges each of the elements from `$data` into each of the entities in `$entities
+ * and recursively does the same for each one of the association names passed in
+ * `$include`. When merging associations, if an entity is not present in the parent
+ * entity for such association, a new one will be created.
+ *
+ * Records in `$data` are matched against the entities by using the primary key
+ * column. Entries in `$entities` that cannot be matched to any record in
+ * `$data` will be discarded. Records in `$data` that could not be matched will
+ * be marshalled as a new entity.
+ *
+ * When merging HasMany or BelongsToMany associations, all the entities in the
+ * `$data` array will appear, those that can be matched by primary key will get
+ * the data merged, but those that cannot, will be discarded.
+ *
+ * @param array|\Traversable $entities the entities that will get the
+ * data merged in
+ * @param array $data list of arrays to be merged into the entities
+ * @param array $include The list of associations to be merged
+ * @return array
+ */
+	public function mergeMany($entities, array $data, $include = []) {
+		$primary = (array)$this->_table->primaryKey();
+		$indexed = (new Collection($data))->groupBy($primary[0])->toArray();
+		$new = isset($indexed[null]) ? [$indexed[null]] : [];
+		unset($indexed[null]);
+		$output = [];
+
+		foreach ($entities as $entity) {
+			if (!($entity instanceof EntityInterface)) {
+				continue;
+			}
+
+			$key = $entity->get($primary[0]);
+
+			if ($key === null || !isset($indexed[$key])) {
+				continue;
+			}
+
+			$output[] = $this->merge($entity, $indexed[$key][0], $include);
+			unset($indexed[$key]);
+		}
+
+		foreach (array_merge($indexed, $new) as $record) {
+			foreach ($record as $value) {
+				$output[] = $this->one($value, $include);
+			}
+		}
+		return $output;
+	}
+
+/**
+ * Creates a new sub-marshaller and merges the associated data.
+ *
+ * @param \Cake\Datasource\EntityInterface $original
+ * @param \Cake\ORM\Association $assoc
+ * @param array $value The data to hydrate
+ * @param array $include The associations to include.
+ * @return mixed
+ */
+	protected function _mergeAssociation($original, $assoc, $value, $include) {
+		if (!$original) {
+			return $this->_marshalAssociation($assoc, $value, $include);
+		}
+
+		$targetTable = $assoc->target();
+		$marshaller = $targetTable->marshaller();
+		if ($assoc->type() === Association::ONE_TO_ONE) {
+			return $marshaller->merge($original, $value, (array)$include);
+		}
+		if ($assoc->type() === Association::MANY_TO_MANY) {
+			return $marshaller->_mergeBelongsToMany($original, $assoc, $value, (array)$include);
+		}
+		return $marshaller->mergeMany($original, $value, (array)$include);
+	}
+
+/**
+ * Creates a new sub-marshaller and merges the associated data for a BelongstoMany
+ * association.
+ *
+ * @param \Cake\Datasource\EntityInterface $original
+ * @param \Cake\ORM\Association $assoc
+ * @param array $value The data to hydrate
+ * @param array $include The associations to include.
+ * @return mixed
+ */
+	protected function _mergeBelongsToMany($original, $assoc, $data, $include) {
+		if (isset($data['_ids']) && is_array($data['_ids'])) {
+			return $this->_loadBelongsToMany($assoc, $data['_ids']);
+		}
+
+		if (!in_array('_joinData', $include) && !isset($include['_joinData'])) {
+			return $this->mergeMany($original, $data, $include);
+		}
+
+		$extra = [];
+		foreach ($original as $entity) {
+			$joinData = $entity->get('_joinData');
+			if ($joinData && $joinData instanceof EntityInterface) {
+				$extra[spl_object_hash($entity)] = $joinData;
+			}
+		}
+
+		$joint = $assoc->junction();
+		$marshaller = $joint->marshaller();
+
+		$nested = [];
+		if (isset($include['_joinData']['associated'])) {
+			$nested = (array)$include['_joinData']['associated'];
+		}
+
+		$records = $this->mergeMany($original, $data, $include);
+		foreach ($records as $record) {
+			$hash = spl_object_hash($record);
+			$data = $record->get('_joinData');
+			if (isset($extra[$hash])) {
+				$record->set('_joinData', $marshaller->merge($extra[$hash], (array)$data, $nested));
+			} else {
+				$joinData = $marshaller->one($data, $nested);
+				$record->set('_joinData', $joinData);
+			}
+		}
+
+		return $records;
+	}
+
 }

+ 34 - 0
src/ORM/Table.php

@@ -1572,6 +1572,40 @@ class Table implements RepositoryInterface, EventListener {
 	}
 
 /**
+ * {@inheritdoc}
+ *
+ * When merging HasMany or BelongsToMany associations, all the entities in the
+ * `$data` array will appear, those that can be matched by primary key will get
+ * the data merged, but those that cannot, will be discarded.
+ */
+	public function patchEntity(EntityInterface $entity, array $data, $associations = null) {
+		if ($associations === null) {
+			$associations = $this->_associated->keys();
+		}
+		$marshaller = $this->marshaller();
+		return $marshaller->merge($entity, $data, $associations);
+	}
+
+/**
+ * {@inheritdoc}
+ *
+ * Those entries in `$entities` that cannot be matched to any record in
+ * `$data` will be discarded. Records in `$data` that could not be matched will
+ * be marshalled as a new entity.
+ *
+ * When merging HasMany or BelongsToMany associations, all the entities in the
+ * `$data` array will appear, those that can be matched by primary key will get
+ * the data merged, but those that cannot, will be discarded.
+ */
+	public function patchEntities($entities, array $data, $associations = null) {
+		if ($associations === null) {
+			$associations = $this->_associated->keys();
+		}
+		$marshaller = $this->marshaller();
+		return $marshaller->mergeMany($entities, $data, $associations);
+	}
+
+/**
  * Validates a single entity based on the passed options and validates
  * any nested entity for this table associations as requested in the
  * options array.

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

@@ -482,4 +482,426 @@ class MarshallerTest extends TestCase {
 		$this->assertInstanceOf('Cake\ORM\Entity', $result->tags[2]);
 	}
 
+/**
+ * Test merge() in a simple use.
+ *
+ * @return void
+ */
+	public function testMergeSimple() {
+		$data = [
+			'title' => 'My title',
+			'author_id' => 1,
+			'not_in_schema' => true
+		];
+		$marshall = new Marshaller($this->articles);
+		$entity = new Entity([
+			'title' => 'Foo',
+			'body' => 'My Content'
+		]);
+		$entity->accessible('*', true);
+		$entity->isNew(false);
+		$entity->clean();
+		$result = $marshall->merge($entity, $data, []);
+
+		$this->assertSame($entity, $result);
+		$this->assertEquals($data + ['body' => 'My Content'], $result->toArray());
+		$this->assertTrue($result->dirty(), 'Should be a dirty entity.');
+		$this->assertFalse($result->isNew(), 'Should not change the entity state');
+	}
+
+/**
+ * Tests that merge respects the entity accessible methods
+ *
+ * @return void
+ */
+	public function testMergeWhitelist() {
+		$data = [
+			'title' => 'My title',
+			'author_id' => 1,
+			'not_in_schema' => true
+		];
+		$marshall = new Marshaller($this->articles);
+		$entity = new Entity([
+			'title' => 'Foo',
+			'body' => 'My Content'
+		]);
+		$entity->accessible('author_id', true);
+		$entity->isNew(false);
+		$result = $marshall->merge($entity, $data, []);
+
+		$expected = [
+			'title' => 'Foo',
+			'body' => 'My Content',
+			'author_id' => 1
+		];
+		$this->assertEquals($expected, $result->toArray());
+	}
+
+/**
+ * Tests that fields with the same value are not marked as dirty
+ *
+ * @return void
+ */
+	public function testMergeDirty() {
+		$marshall = new Marshaller($this->articles);
+		$entity = new Entity([
+			'title' => 'Foo',
+			'author_id' => 1
+		]);
+		$data = [
+			'title' => 'Foo',
+			'author_id' => 1,
+			'crazy' => true
+		];
+		$entity->accessible('*', true);
+		$entity->clean();
+		$result = $marshall->merge($entity, $data, []);
+
+		$expected = [
+			'title' => 'Foo',
+			'author_id' => 1,
+			'crazy' => true
+		];
+		$this->assertEquals($expected, $result->toArray());
+		$this->assertFalse($entity->dirty('title'));
+		$this->assertFalse($entity->dirty('author_id'));
+		$this->assertTrue($entity->dirty('crazy'));
+	}
+
+/**
+ * Tests merging data into an associated entity
+ *
+ * @return void
+ */
+	public function testMergeWithSingleAssociation() {
+		$user = new Entity([
+			'username' => 'mark',
+			'password' => 'secret'
+		]);
+		$entity = new Entity([
+			'tile' => 'My Title',
+			'user' => $user
+		]);
+		$user->accessible('*', true);
+		$entity->accessible('*', true);
+
+		$data = [
+			'body' => 'My Content',
+			'user' => [
+				'password' => 'not a secret'
+			]
+		];
+		$marshall = new Marshaller($this->articles);
+		$marshall->merge($entity, $data, ['Users']);
+		$this->assertEquals('My Content', $entity->body);
+		$this->assertSame($user, $entity->user);
+		$this->assertEquals('mark', $entity->user->username);
+		$this->assertEquals('not a secret', $entity->user->password);
+		$this->assertTrue($entity->dirty('user'));
+	}
+
+/**
+ * Tests that new associated entities can be created when merging data into
+ * a parent entity
+ *
+ * @return void
+ */
+	public function testMergeCreateAssociation() {
+		$entity = new Entity([
+			'tile' => 'My Title'
+		]);
+		$entity->accessible('*', true);
+		$data = [
+			'body' => 'My Content',
+			'user' => [
+				'username' => 'mark',
+				'password' => 'not a secret'
+			]
+		];
+		$marshall = new Marshaller($this->articles);
+		$marshall->merge($entity, $data, ['Users']);
+		$this->assertEquals('My Content', $entity->body);
+		$this->assertInstanceOf('Cake\ORM\Entity', $entity->user);
+		$this->assertEquals('mark', $entity->user->username);
+		$this->assertEquals('not a secret', $entity->user->password);
+		$this->assertTrue($entity->dirty('user'));
+		$this->assertNull($entity->user->isNew());
+	}
+
+/**
+ * Tests merging one to many associations
+ *
+ * @return void
+ */
+	public function testMergeMultipleAssociations() {
+		$user = new Entity(['username' => 'mark', 'password' => 'secret']);
+		$comment1 = new Entity(['id' => 1, 'comment' => 'A comment']);
+		$comment2 = new Entity(['id' => 2, 'comment' => 'Another comment']);
+		$entity = new Entity([
+			'title' => 'My Title',
+			'user' => $user,
+			'comments' => [$comment1, $comment2]
+		]);
+
+		$user->accessible('*', true);
+		$comment1->accessible('*', true);
+		$comment2->accessible('*', true);
+		$entity->accessible('*', true);
+
+		$data = [
+			'title' => 'Another title',
+			'user' => ['password' => 'not so secret'],
+			'comments' => [
+				['comment' => 'Extra comment 1'],
+				['id' => 2, 'comment' => 'Altered comment 2'],
+				['id' => 1, 'comment' => 'Altered comment 1'],
+				['id' => 3, 'comment' => 'Extra comment 3'],
+				['comment' => 'Extra comment 2']
+			]
+		];
+		$marshall = new Marshaller($this->articles);
+		$result = $marshall->merge($entity, $data, ['Users', 'Comments']);
+		$this->assertSame($entity, $result);
+		$this->assertSame($user, $result->user);
+		$this->assertEquals('not so secret', $entity->user->password);
+		$this->assertSame($comment1, $entity->comments[0]);
+		$this->assertSame($comment2, $entity->comments[1]);
+		$this->assertEquals('Altered comment 1', $entity->comments[0]->comment);
+		$this->assertEquals('Altered comment 2', $entity->comments[1]->comment);
+		$this->assertEquals(
+			['comment' => 'Extra comment 3', 'id' => 3],
+			$entity->comments[2]->toArray()
+		);
+		$this->assertEquals(
+			['comment' => 'Extra comment 1'],
+			$entity->comments[3]->toArray()
+		);
+		$this->assertEquals(
+			['comment' => 'Extra comment 2'],
+			$entity->comments[4]->toArray()
+		);
+	}
+
+/**
+ * Tests that merging data to an entity containing belongsToMany and _ids
+ * will just overwrite the data
+ *
+ * @return void
+ */
+	public function testMergeBelongsToManyEntitiesFromIds() {
+		$entity = new Entity([
+			'title' => 'Haz tags',
+			'body' => 'Some content here',
+			'tags' => [
+				new Entity(['id' => 1, 'name' => 'Cake']),
+				new Entity(['id' => 2, 'name' => 'PHP'])
+			]
+		]);
+
+		$data = [
+			'title' => 'Haz moar tags',
+			'tags' => ['_ids' => [1, 2, 3]]
+		];
+		$entity->accessible('*', true);
+		$marshall = new Marshaller($this->articles);
+		$result = $marshall->merge($entity, $data, ['Tags']);
+
+		$this->assertCount(3, $result->tags);
+		$this->assertInstanceOf('Cake\ORM\Entity', $result->tags[0]);
+		$this->assertInstanceOf('Cake\ORM\Entity', $result->tags[1]);
+		$this->assertInstanceOf('Cake\ORM\Entity', $result->tags[2]);
+	}
+
+/**
+ * Test merging the _joinData entity for belongstomany associations.
+ *
+ * @return void
+ */
+	public function testMergeBelongsToManyJoinData() {
+		$data = [
+			'title' => 'My title',
+			'body' => 'My content',
+			'author_id' => 1,
+			'tags' => [
+				[
+					'id' => 1,
+					'tag' => 'news',
+					'_joinData' => [
+						'active' => 0
+					]
+				],
+				[
+					'id' => 2,
+					'tag' => 'cakephp',
+					'_joinData' => [
+						'active' => 0
+					]
+				],
+			],
+		];
+
+		$options = ['Tags' => ['associated' => ['_joinData']]];
+		$marshall = new Marshaller($this->articles);
+		$entity = $marshall->one($data, $options);
+		$entity->accessible('*', true);
+
+		$data = [
+			'title' => 'Haz data',
+			'tags' => [
+				['id' => 1, 'tag' => 'Cake', '_joinData' => ['foo' => 'bar']],
+				['tag' => 'new tag', '_joinData' => ['active' => 1, 'foo' => 'baz']]
+			]
+		];
+
+		$tag1 = $entity->tags[0];
+		$result = $marshall->merge($entity, $data, $options);
+		$this->assertEquals($data['title'], $result->title);
+		$this->assertEquals('My content', $result->body);
+		$this->assertSame($tag1, $entity->tags[0]);
+		$this->assertSame($tag1->_joinData, $entity->tags[0]->_joinData);
+		$this->assertSame(
+			['active' => 0, 'foo' => 'bar'],
+			$entity->tags[0]->_joinData->toArray()
+		);
+		$this->assertSame(
+			['active' => 1, 'foo' => 'baz'],
+			$entity->tags[1]->_joinData->toArray()
+		);
+		$this->assertEquals('new tag', $entity->tags[1]->tag);
+		$this->assertTrue($entity->tags[0]->dirty('_joinData'));
+		$this->assertTrue($entity->tags[1]->dirty('_joinData'));
+	}
+
+/**
+ * Test merging associations inside _joinData
+ *
+ * @return void
+ */
+	public function testMergeJoinDataAssociations() {
+		$data = [
+			'title' => 'My title',
+			'body' => 'My content',
+			'author_id' => 1,
+			'tags' => [
+				[
+					'id' => 1,
+					'tag' => 'news',
+					'_joinData' => [
+						'active' => 0,
+						'user' => ['username' => 'Bill']
+					]
+				],
+				[
+					'id' => 2,
+					'tag' => 'cakephp',
+					'_joinData' => [
+						'active' => 0
+					]
+				],
+			]
+		];
+
+		$articlesTags = TableRegistry::get('ArticlesTags');
+		$articlesTags->belongsTo('Users');
+
+		$options = [
+			'Tags' => [
+				'associated' => [
+					'_joinData' => ['associated' => ['Users']]
+				]
+			]
+		];
+		$marshall = new Marshaller($this->articles);
+		$entity = $marshall->one($data, $options);
+		$entity->accessible('*', true);
+
+		$data = [
+			'title' => 'Haz data',
+			'tags' => [
+				[
+					'id' => 1,
+					'tag' => 'news',
+					'_joinData' => [
+						'foo' => 'bar',
+						'user' => ['password' => 'secret']
+					]
+				],
+				[
+					'id' => 2,
+					'_joinData' => [
+						'active' => 1,
+						'foo' => 'baz',
+						'user' => ['username' => 'ber']
+					]
+				]
+			]
+		];
+
+		$tag1 = $entity->tags[0];
+		$result = $marshall->merge($entity, $data, $options);
+		$this->assertEquals($data['title'], $result->title);
+		$this->assertEquals('My content', $result->body);
+		$this->assertSame($tag1, $entity->tags[0]);
+		$this->assertSame($tag1->_joinData, $entity->tags[0]->_joinData);
+		$this->assertEquals('Bill', $entity->tags[0]->_joinData->user->username);
+		$this->assertEquals('secret', $entity->tags[0]->_joinData->user->password);
+		$this->assertEquals('ber', $entity->tags[1]->_joinData->user->username);
+	}
+
+/**
+ * Test mergeMany() with a simple set of data.
+ *
+ * @return void
+ */
+	public function testMergeManySimple() {
+		$entities = [
+			new OpenEntity(['id' => 1, 'comment' => 'First post', 'user_id' => 2]),
+			new OpenEntity(['id' => 2, 'comment' => 'Second post', 'user_id' => 2])
+		];
+		$entities[0]->clean();
+		$entities[1]->clean();
+
+		$data = [
+			['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2],
+			['id' => 1, 'comment' => 'Changed 1', 'user_id' => 1]
+		];
+		$marshall = new Marshaller($this->comments);
+		$result = $marshall->mergeMany($entities, $data);
+
+		$this->assertSame($entities[0], $result[0]);
+		$this->assertSame($entities[1], $result[1]);
+		$this->assertEquals('Changed 1', $result[0]->comment);
+		$this->assertEquals(1, $result[0]->user_id);
+		$this->assertEquals('Changed 2', $result[1]->comment);
+		$this->assertTrue($result[0]->dirty('user_id'));
+		$this->assertFalse($result[1]->dirty('user_id'));
+	}
+
+/**
+ * Tests that only records found in the data array are returned, those that cannot
+ * be matched are discarded
+ *
+ * @return void
+ */
+	public function testMergeManyWithAppend() {
+		$entities = [
+			new OpenEntity(['comment' => 'First post', 'user_id' => 2]),
+			new OpenEntity(['id' => 2, 'comment' => 'Second post', 'user_id' => 2])
+		];
+		$entities[0]->clean();
+		$entities[1]->clean();
+
+		$data = [
+			['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2],
+			['id' => 1, 'comment' => 'Comment 1', 'user_id' => 1]
+		];
+		$marshall = new Marshaller($this->comments);
+		$result = $marshall->mergeMany($entities, $data);
+
+		$this->assertCount(2, $result);
+		$this->assertNotSame($entities[0], $result[0]);
+		$this->assertSame($entities[1], $result[0]);
+		$this->assertEquals('Changed 2', $result[0]->comment);
+	}
+
 }

+ 46 - 0
tests/TestCase/ORM/TableTest.php

@@ -3197,4 +3197,50 @@ class TableTest extends \Cake\TestSuite\TestCase {
 		$this->assertFalse($table->validateMany($entities, $options));
 	}
 
+/**
+ * Tests that patchEntity delegates the task to the marshaller and passed
+ * all associations
+ *
+ * @return void
+ */
+	public function testPatchEntity() {
+		$table = $this->getMock('Cake\ORM\Table', ['marshaller']);
+		$marshaller = $this->getMock('Cake\ORM\Marshaller', [], [$table]);
+		$table->belongsTo('users');
+		$table->hasMany('articles');
+		$table->expects($this->once())->method('marshaller')
+			->will($this->returnValue($marshaller));
+
+		$entity = new \Cake\ORM\Entity;
+		$data = ['foo' => 'bar'];
+		$marshaller->expects($this->once())
+			->method('merge')
+			->with($entity, $data, ['users', 'articles'])
+			->will($this->returnValue($entity));
+		$table->patchEntity($entity, $data);
+	}
+
+/**
+ * Tests that patchEntities delegates the task to the marshaller and passed
+ * all associations
+ *
+ * @return void
+ */
+	public function testPatchEntities() {
+		$table = $this->getMock('Cake\ORM\Table', ['marshaller']);
+		$marshaller = $this->getMock('Cake\ORM\Marshaller', [], [$table]);
+		$table->belongsTo('users');
+		$table->hasMany('articles');
+		$table->expects($this->once())->method('marshaller')
+			->will($this->returnValue($marshaller));
+
+		$entities = [new \Cake\ORM\Entity];
+		$data = [['foo' => 'bar']];
+		$marshaller->expects($this->once())
+			->method('mergeMany')
+			->with($entities, $data, ['users', 'articles'])
+			->will($this->returnValue($entities));
+		$table->patchEntities($entities, $data);
+	}
+
 }