Browse Source

Merge pull request #3221 from cakephp/3.0-duplicate-aliases

3.0 duplicate aliases

Fixes #3113
Mark Story 12 years ago
parent
commit
5fdee2cec7

+ 50 - 20
src/ORM/Association.php

@@ -14,6 +14,7 @@
  */
 namespace Cake\ORM;
 
+use Cake\Database\Expression\IdentifierExpression;
 use Cake\Datasource\ResultSetDecorator;
 use Cake\Event\Event;
 use Cake\ORM\Entity;
@@ -446,27 +447,23 @@ abstract class Association {
  * source results.
  *
  * @param array $row
+ * @param string $nestKey The array key under which the results for this association
+ * should be found
  * @param boolean $joined Whether or not the row is a result of a direct join
  * with this association
  * @return array
  */
-	public function transformRow($row, $joined) {
+	public function transformRow($row, $nestKey, $joined) {
 		$sourceAlias = $this->source()->alias();
-		$targetAlias = $this->target()->alias();
+		$nestKey = $nestKey ?: $this->_name;
 		if (isset($row[$sourceAlias])) {
-			$row[$sourceAlias][$this->property()] = $row[$targetAlias];
+			$row[$sourceAlias][$this->property()] = $row[$nestKey];
+			unset($row[$nestKey]);
 		}
 		return $row;
 	}
 
 /**
- * Get the relationship type.
- *
- * @return string Constant of either ONE_TO_ONE, MANY_TO_ONE, ONE_TO_MANY or MANY_TO_MANY.
- */
-	public abstract function type();
-
-/**
  * Proxies the finding operation to the target table's find method
  * and modifies the query accordingly based of this association
  * configuration
@@ -582,6 +579,48 @@ abstract class Association {
 	}
 
 /**
+ * Returns a single or multiple conditions to be appended to the generated join
+ * clause for getting the results on the target table.
+ *
+ * @param array $options list of options passed to attachTo method
+ * @return array
+ * @throws \RuntimeException if the number of columns in the foreignKey do not
+ * match the number of columns in the source table primaryKey
+ */
+	protected function _joinCondition(array $options) {
+		$conditions = [];
+		$tAlias = $this->target()->alias();
+		$sAlias = $this->source()->alias();
+		$foreignKey = (array)$options['foreignKey'];
+		$primaryKey = (array)$this->_sourceTable->primaryKey();
+
+		if (count($foreignKey) !== count($primaryKey)) {
+			$msg = 'Cannot match provided foreignKey for "%s", got "(%s)" but expected foreign key for "(%s)"';
+			throw new \RuntimeException(sprintf(
+				$msg,
+				$this->_name,
+				implode(', ', $foreignKey),
+				implode(', ', $primaryKey)
+			));
+		}
+
+		foreach ($foreignKey as $k => $f) {
+			$field = sprintf('%s.%s', $sAlias, $primaryKey[$k]);
+			$value = new IdentifierExpression(sprintf('%s.%s', $tAlias, $f));
+			$conditions[$field] = $value;
+		}
+
+		return $conditions;
+	}
+
+/**
+ * Get the relationship type.
+ *
+ * @return string Constant of either ONE_TO_ONE, MANY_TO_ONE, ONE_TO_MANY or MANY_TO_MANY.
+ */
+	public abstract function type();
+
+/**
  * Eager loads a list of records in the target table that are related to another
  * set of records in the source table. Source records can specified in two ways:
  * first one is by passing a Query object setup to find on the source table and
@@ -606,6 +645,7 @@ abstract class Association {
  * - fields: List of fields to select from the target table
  * - contain: List of related tables to eager load associated to the target table
  * - strategy: The name of strategy to use for finding target table records
+ * - nestKey: The array key under which results will be found when transforming the row
  *
  * @param array $options
  * @return \Closure
@@ -613,16 +653,6 @@ abstract class Association {
 	public abstract function eagerLoader(array $options);
 
 /**
- * Returns a single or multiple condition(s) to be appended to the generated join
- * clause for getting the results on the target table. If false is returned then
- * it will not attach any new conditions to the join clause
- *
- * @param array $options list of options passed to attachTo method
- * @return string|array|boolean
- */
-	protected abstract function _joinCondition(array $options);
-
-/**
  * Handles cascading a delete from an associated model.
  *
  * Each implementing class should handle the cascaded delete as

+ 7 - 19
src/ORM/Association/BelongsTo.php

@@ -103,23 +103,6 @@ class BelongsTo extends Association {
 	}
 
 /**
- * {@inheritdoc}
- *
- */
-	public function transformRow($row, $joined) {
-		if ($this->strategy() === $this::STRATEGY_JOIN) {
-			return parent::transformRow($row, $joined);
-		}
-
-		$sourceAlias = $this->source()->alias();
-		$nestKey = $this->_nestingKey();
-		if (isset($row[$nestKey])) {
-			$row[$sourceAlias][$this->property()] = $row[$nestKey];
-		}
-		return $row;
-	}
-
-/**
  * Takes an entity from the source table and looks if there is a field
  * matching the property name for this association. The found entity will be
  * saved on the target table for this association by passing supplied
@@ -169,8 +152,13 @@ class BelongsTo extends Association {
 		$primaryKey = (array)$this->_targetTable->primaryKey();
 
 		if (count($foreignKey) !== count($primaryKey)) {
-			$msg = 'Cannot match provided foreignKey, got %d columns expected %d';
-			throw new \RuntimeException(sprintf($msg, count($foreignKey), count($primaryKey)));
+			$msg = 'Cannot match provided foreignKey for "%s", got "(%s)" but expected foreign key for "(%s)"';
+			throw new \RuntimeException(sprintf(
+				$msg,
+				$this->_name,
+				implode(', ', $foreignKey),
+				implode(', ', $primaryKey)
+			));
 		}
 
 		foreach ($foreignKey as $k => $f) {

+ 4 - 16
src/ORM/Association/BelongsToMany.php

@@ -32,7 +32,6 @@ class BelongsToMany extends Association {
 	use ExternalAssociationTrait {
 		_options as _externalOptions;
 		_addFilteringCondition as _addExternalConditions;
-		transformRow as protected _transformRow;
 	}
 
 /**
@@ -50,13 +49,6 @@ class BelongsToMany extends Association {
 	const SAVE_REPLACE = 'replace';
 
 /**
- * Whether this association can be expressed directly in a query join
- *
- * @var boolean
- */
-	protected $_canBeJoined = false;
-
-/**
  * The type of join to be used when adding the association to a query
  *
  * @var string
@@ -243,21 +235,17 @@ class BelongsToMany extends Association {
 	}
 
 /**
- * Correctly nests a result row associated values into the correct array keys inside the
- * source results.
+ * {@inheritdoc}
  *
- * @param array $row
- * @param boolean $joined Whether or not the row is a result of a direct join
- * with this association
- * @return array
  */
-	public function transformRow($row, $joined) {
+	public function transformRow($row, $nestKey, $joined) {
 		$alias = $this->junction()->alias();
 		if ($joined) {
 			$row[$this->target()->alias()][$this->_junctionProperty] = $row[$alias];
 			unset($row[$alias]);
 		}
-		return $this->_transformRow($row, $joined);
+
+		return parent::transformRow($row, $nestKey, $joined);
 	}
 
 /**

+ 0 - 64
src/ORM/Association/ExternalAssociationTrait.php

@@ -14,7 +14,6 @@
  */
 namespace Cake\ORM\Association;
 
-use Cake\Database\Expression\IdentifierExpression;
 use Cake\ORM\Association\SelectableAssociationTrait;
 use Cake\ORM\Query;
 use Cake\Utility\Inflector;
@@ -79,30 +78,6 @@ trait ExternalAssociationTrait {
 	}
 
 /**
- * Correctly nests a result row associated values into the correct array keys inside the
- * source results.
- *
- * @param array $row
- * @param boolean $joined Whether or not the row is a result of a direct join
- * with this association
- * @return array
- */
-	public function transformRow($row, $joined) {
-		$sourceAlias = $this->source()->alias();
-		$targetAlias = $this->target()->alias();
-
-		$collectionAlias = $this->_name . '___collection_';
-		if (isset($row[$collectionAlias])) {
-			$values = $row[$collectionAlias];
-		} else {
-			$values = $row[$this->_name];
-		}
-
-		$row[$sourceAlias][$this->property()] = $values;
-		return $row;
-	}
-
-/**
  * Returns the default options to use for the eagerLoader
  *
  * @return array
@@ -132,45 +107,6 @@ trait ExternalAssociationTrait {
 	}
 
 /**
- * Returns the key under which the eagerLoader will put this association results
- *
- * @return void
- */
-	protected function _nestingKey() {
-		return $this->_name . '___collection_';
-	}
-
-/**
- * Returns a single or multiple conditions to be appended to the generated join
- * clause for getting the results on the target table.
- *
- * @param array $options list of options passed to attachTo method
- * @return array
- * @throws \RuntimeException if the number of columns in the foreignKey do not
- * match the number of columns in the source table primaryKey
- */
-	protected function _joinCondition(array $options) {
-		$conditions = [];
-		$tAlias = $this->target()->alias();
-		$sAlias = $this->source()->alias();
-		$foreignKey = (array)$options['foreignKey'];
-		$primaryKey = (array)$this->_sourceTable->primaryKey();
-
-		if (count($foreignKey) !== count($primaryKey)) {
-			$msg = 'Cannot match provided foreignKey, got %d columns expected %d';
-			throw new \RuntimeException(sprintf($msg, count($foreignKey), count($primaryKey)));
-		}
-
-		foreach ($foreignKey as $k => $f) {
-			$field = sprintf('%s.%s', $sAlias, $primaryKey[$k]);
-			$value = new IdentifierExpression(sprintf('%s.%s', $tAlias, $f));
-			$conditions[$field] = $value;
-		}
-
-		return $conditions;
-	}
-
-/**
  * Parse extra options passed in the constructor.
  *
  * @param array $opts original list of options passed in constructor

+ 0 - 31
src/ORM/Association/HasOne.php

@@ -14,7 +14,6 @@
  */
 namespace Cake\ORM\Association;
 
-use Cake\Database\Expression\IdentifierExpression;
 use Cake\ORM\Association;
 use Cake\ORM\Association\DependentDeleteTrait;
 use Cake\ORM\Association\SelectableAssociationTrait;
@@ -132,36 +131,6 @@ class HasOne extends Association {
 	}
 
 /**
- * Returns a single or multiple conditions to be appended to the generated join
- * clause for getting the results on the target table.
- *
- * @param array $options list of options passed to attachTo method
- * @return array
- * @throws \RuntimeException if the number of columns in the foreignKey do not
- * match the number of columns in the source table primaryKey
- */
-	protected function _joinCondition(array $options) {
-		$conditions = [];
-		$tAlias = $this->target()->alias();
-		$sAlias = $this->_sourceTable->alias();
-		$foreignKey = (array)$options['foreignKey'];
-		$primaryKey = (array)$this->_sourceTable->primaryKey();
-
-		if (count($foreignKey) !== count($primaryKey)) {
-			$msg = 'Cannot match provided foreignKey, got %d columns expected %d';
-			throw new \RuntimeException(sprintf($msg, count($foreignKey), count($primaryKey)));
-		}
-
-		foreach ($foreignKey as $k => $f) {
-			$field = sprintf('%s.%s', $sAlias, $primaryKey[$k]);
-			$value = new IdentifierExpression(sprintf('%s.%s', $tAlias, $f));
-			$conditions[$field] = $value;
-		}
-
-		return $conditions;
-	}
-
-/**
  * {@inheritdoc}
  *
  */

+ 6 - 14
src/ORM/Association/SelectableAssociationTrait.php

@@ -50,7 +50,7 @@ trait SelectableAssociationTrait {
 			$fetchQuery = $queryBuilder($fetchQuery);
 		}
 		$resultMap = $this->_buildResultMap($fetchQuery, $options);
-		return $this->_resultInjector($fetchQuery, $resultMap);
+		return $this->_resultInjector($fetchQuery, $resultMap, $options);
 	}
 
 /**
@@ -62,7 +62,8 @@ trait SelectableAssociationTrait {
 		return [
 			'foreignKey' => $this->foreignKey(),
 			'conditions' => [],
-			'strategy' => $this->strategy()
+			'strategy' => $this->strategy(),
+			'nestKey' => $this->_name
 		];
 	}
 
@@ -79,7 +80,6 @@ trait SelectableAssociationTrait {
 		$target = $this->target();
 		$alias = $target->alias();
 		$key = $this->_linkField($options);
-
 		$filter = $options['keys'];
 
 		if ($options['strategy'] === $this::STRATEGY_SUBQUERY) {
@@ -198,9 +198,10 @@ trait SelectableAssociationTrait {
  * @param \Cake\ORM\Query $fetchQuery the Query used to fetch results
  * @param array $resultMap an array with the foreignKey as keys and
  * the corresponding target table results as value.
+ * @param array $options The options passed to the eagerLoader method
  * @return \Closure
  */
-	protected function _resultInjector($fetchQuery, $resultMap) {
+	protected function _resultInjector($fetchQuery, $resultMap, $options) {
 		$source = $this->source();
 		$sAlias = $source->alias();
 		$keys = $this->type() === $this::MANY_TO_ONE ?
@@ -212,7 +213,7 @@ trait SelectableAssociationTrait {
 			$sourceKeys[] = key($fetchQuery->aliasField($key, $sAlias));
 		}
 
-		$nestKey = $this->_nestingKey();
+		$nestKey = $options['nestKey'];
 		if (count($sourceKeys) > 1) {
 			return $this->_multiKeysInjector($resultMap, $sourceKeys, $nestKey);
 		}
@@ -227,15 +228,6 @@ trait SelectableAssociationTrait {
 	}
 
 /**
- * Returns the key under which the eagerLoader will put this association results
- *
- * @return string
- */
-	protected function _nestingKey() {
-		return $this->property();
-	}
-
-/**
  * Returns a callable to be used for each row in a query result set
  * for injecting the eager loaded rows when the matching needs to
  * be done with multiple foreign keys

+ 54 - 4
src/ORM/EagerLoader.php

@@ -68,6 +68,13 @@ class EagerLoader {
 	protected $_loadExternal = [];
 
 /**
+ * Contains a list of the association names that are to be eagerly loaded
+ *
+ * @var array
+ */
+	protected $_aliasList = [];
+
+/**
  * Sets the list of associations that should be eagerly loaded along for a
  * specific table using when a query is provided. The list of associated tables
  * passed to this method must have been previously set as associations using the
@@ -99,6 +106,7 @@ class EagerLoader {
 		$associations = (array)$associations;
 		$associations = $this->_reformatContain($associations, $this->_containments);
 		$this->_normalized = $this->_loadExternal = null;
+		$this->_aliasList = [];
 		return $this->_containments = $associations;
 	}
 
@@ -156,7 +164,7 @@ class EagerLoader {
 				$repository,
 				$alias,
 				$options,
-				[]
+				['root' => null]
 			);
 		}
 
@@ -304,7 +312,7 @@ class EagerLoader {
 			);
 		}
 
-		$paths += ['aliasPath' => '', 'propertyPath' => ''];
+		$paths += ['aliasPath' => '', 'propertyPath' => '', 'root' => $alias];
 		$paths['aliasPath'] .= '.' . $alias;
 		$paths['propertyPath'] .= '.' . $instance->property();
 
@@ -316,9 +324,16 @@ class EagerLoader {
 			'instance' => $instance,
 			'config' => array_diff_key($options, $extra),
 			'aliasPath' => trim($paths['aliasPath'], '.'),
-			'propertyPath' => trim($paths['propertyPath'], '.'),
+			'propertyPath' => trim($paths['propertyPath'], '.')
 		];
 		$config['canBeJoined'] = $instance->canBeJoined($config['config']);
+		$config = $this->_correctStrategy($alias, $config, $paths['root']);
+
+		if ($config['canBeJoined']) {
+			$this->_aliasList[$paths['root']][$alias] = true;
+		} else {
+			$paths['root'] = $config['aliasPath'];
+		}
 
 		foreach ($extra as $t => $assoc) {
 			$config['associations'][$t] = $this->_normalizeContain($table, $t, $assoc, $paths);
@@ -328,6 +343,36 @@ class EagerLoader {
 	}
 
 /**
+ * Changes the association fetching strategy if required because of duplicate
+ * under the same direct associations chain
+ *
+ * @param string $alias the name of the association to evaluate
+ * @param array $config The association config
+ * @param string $root An string representing the root association that started
+ * the direct chain this alias is in
+ * @return array The modified association config
+ * @throws \RuntimeException if a duplicate association in the same chain is detected
+ * but is not possible to change the strategy due to conflicting settings
+ */
+	protected function _correctStrategy($alias, $config, $root) {
+		if (!$config['canBeJoined'] || empty($this->_aliasList[$root][$alias])) {
+			return $config;
+		}
+
+		if (!empty($config['config']['matching'])) {
+			throw new \RuntimeException(sprintf(
+				'Cannot use "matching" on "%s" as there is another association with the same alias',
+				$alias
+			));
+		}
+
+		$config['canBeJoined'] = false;
+		$config['config']['strategy'] = $config['instance']::STRATEGY_SELECT;
+
+		return $config;
+	}
+
+/**
  * Helper function used to compile a list of all associations that can be
  * joined in the query.
  *
@@ -375,7 +420,12 @@ class EagerLoader {
 
 			$keys = isset($collected[$alias]) ? $collected[$alias] : null;
 			$f = $meta['instance']->eagerLoader(
-				$meta['config'] + ['query' => $query, 'contain' => $contain, 'keys' => $keys]
+				$meta['config'] + [
+					'query' => $query,
+					'contain' => $contain,
+					'keys' => $keys,
+					'nestKey' => $meta['aliasPath']
+				]
 			);
 			$statement = new CallbackStatement($statement, $driver, $f);
 		}

+ 8 - 6
src/ORM/ResultSet.php

@@ -309,9 +309,10 @@ class ResultSet implements Countable, Iterator, Serializable, JsonSerializable {
 					'alias' => $assoc,
 					'instance' => $meta['instance'],
 					'canBeJoined' => $meta['canBeJoined'],
-					'entityClass' => $meta['instance']->target()->entityClass()
+					'entityClass' => $meta['instance']->target()->entityClass(),
+					'nestKey' => $meta['canBeJoined'] ? $assoc : $meta['aliasPath']
 				];
-				if (!empty($meta['associations'])) {
+				if ($meta['canBeJoined'] && !empty($meta['associations'])) {
 					$visitor($meta['associations']);
 				}
 			}
@@ -351,7 +352,7 @@ class ResultSet implements Countable, Iterator, Serializable, JsonSerializable {
 			$table = $defaultAlias;
 			$field = $key;
 
-			if (strpos($key, '___collection_') !== false) {
+			if (isset($this->_associationMap[$key])) {
 				$results[$key] = $value;
 				continue;
 			}
@@ -383,9 +384,10 @@ class ResultSet implements Countable, Iterator, Serializable, JsonSerializable {
 			'markNew' => false,
 			'guard' => false
 		];
+
 		foreach (array_reverse($this->_associationMap) as $assoc) {
-			$alias = $assoc['alias'];
-			if (!isset($results[$alias]) && !isset($results[$alias . '___collection_'])) {
+			$alias = $assoc['nestKey'];
+			if (!isset($results[$alias])) {
 				continue;
 			}
 
@@ -404,7 +406,7 @@ class ResultSet implements Countable, Iterator, Serializable, JsonSerializable {
 				$results[$alias] = $entity;
 			}
 
-			$results = $instance->transformRow($results, $assoc['canBeJoined']);
+			$results = $instance->transformRow($results, $alias, $assoc['canBeJoined']);
 		}
 
 		foreach ($presentAliases as $alias => $present) {

+ 6 - 6
tests/TestCase/ORM/Association/BelongsToManyTest.php

@@ -452,14 +452,14 @@ class BelongsToManyTest extends TestCase {
 		$callable = $association->eagerLoader(compact('keys', 'query'));
 		$row = ['Articles__id' => 1, 'title' => 'article 1'];
 		$result = $callable($row);
-		$row['Tags___collection_'] = [
+		$row['Tags'] = [
 			['id' => 1, 'name' => 'foo', '_joinData' => ['article_id' => 1]]
 		];
 		$this->assertEquals($row, $result);
 
 		$row = ['Articles__id' => 2, 'title' => 'article 2'];
 		$result = $callable($row);
-		$row['Tags___collection_'] = [
+		$row['Tags'] = [
 			['id' => 2, 'name' => 'bar', '_joinData' => ['article_id' => 2]]
 		];
 		$this->assertEquals($row, $result);
@@ -695,14 +695,14 @@ class BelongsToManyTest extends TestCase {
 			'keys' => []
 		]);
 
-		$row['Tags___collection_'] = [
+		$row['Tags'] = [
 			['id' => 1, 'name' => 'foo', '_joinData' => ['article_id' => 1]]
 		];
 		$row['Articles__id'] = 1;
 		$result = $callable($row);
 		$this->assertEquals($row, $result);
 
-		$row['Tags___collection_'] = [
+		$row['Tags'] = [
 			['id' => 2, 'name' => 'bar', '_joinData' => ['article_id' => 2]]
 		];
 		$row['Articles__id'] = 2;
@@ -847,7 +847,7 @@ class BelongsToManyTest extends TestCase {
 		$callable = $association->eagerLoader(compact('keys', 'query'));
 		$row = ['Articles__id' => 1, 'title' => 'article 1', 'Articles__site_id' => 1];
 		$result = $callable($row);
-		$row['Tags___collection_'] = [
+		$row['Tags'] = [
 			[
 				'id' => 1,
 				'name' => 'foo',
@@ -859,7 +859,7 @@ class BelongsToManyTest extends TestCase {
 
 		$row = ['Articles__id' => 2, 'title' => 'article 2', 'Articles__site_id' => 2];
 		$result = $callable($row);
-		$row['Tags___collection_'] = [
+		$row['Tags'] = [
 			[
 				'id' => 2,
 				'name' => 'bar',

+ 1 - 1
tests/TestCase/ORM/Association/BelongsToTest.php

@@ -335,7 +335,7 @@ class BelongsToTest extends \Cake\TestSuite\TestCase {
  * key will work if the foreign key is passed
  *
  * @expectedException \RuntimeException
- * @expectedExceptionMessage Cannot match provided foreignKey, got 1 columns expected 2
+ * @expectedExceptionMessage Cannot match provided foreignKey for "Companies", got "(company_id)" but expected foreign key for "(id, tenant_id)"
  * @return void
  */
 	public function testAttachToMultiPrimaryKeyMistmatch() {

+ 7 - 7
tests/TestCase/ORM/Association/HasManyTest.php

@@ -141,14 +141,14 @@ class HasManyTest extends \Cake\TestSuite\TestCase {
 		$callable = $association->eagerLoader(compact('keys', 'query'));
 		$row = ['Authors__id' => 1, 'username' => 'author 1'];
 		$result = $callable($row);
-		$row['Articles___collection_'] = [
+		$row['Articles'] = [
 			['id' => 2, 'title' => 'article 2', 'author_id' => 1]
 			];
 		$this->assertEquals($row, $result);
 
 		$row = ['Authors__id' => 2, 'username' => 'author 2'];
 		$result = $callable($row);
-		$row['Articles___collection_'] = [
+		$row['Articles'] = [
 			['id' => 1, 'title' => 'article 1', 'author_id' => 2]
 			];
 		$this->assertEquals($row, $result);
@@ -356,14 +356,14 @@ class HasManyTest extends \Cake\TestSuite\TestCase {
 		]);
 		$row = ['Authors__id' => 1, 'username' => 'author 1'];
 		$result = $callable($row);
-		$row['Articles___collection_'] = [
+		$row['Articles'] = [
 			['id' => 2, 'title' => 'article 2', 'author_id' => 1]
 		];
 		$this->assertEquals($row, $result);
 
 		$row = ['Authors__id' => 2, 'username' => 'author 2'];
 		$result = $callable($row);
-		$row['Articles___collection_'] = [
+		$row['Articles'] = [
 			['id' => 1, 'title' => 'article 1', 'author_id' => 2]
 		];
 		$this->assertEquals($row, $result);
@@ -456,14 +456,14 @@ class HasManyTest extends \Cake\TestSuite\TestCase {
 		$callable = $association->eagerLoader(compact('keys', 'query'));
 		$row = ['Authors__id' => 2, 'Authors__site_id' => 10, 'username' => 'author 1'];
 		$result = $callable($row);
-		$row['Articles___collection_'] = [
+		$row['Articles'] = [
 			['id' => 1, 'title' => 'article 1', 'author_id' => 2, 'site_id' => 10]
 		];
 		$this->assertEquals($row, $result);
 
 		$row = ['Authors__id' => 1, 'username' => 'author 2', 'Authors__site_id' => 20];
 		$result = $callable($row);
-		$row['Articles___collection_'] = [
+		$row['Articles'] = [
 			['id' => 2, 'title' => 'article 2', 'author_id' => 1, 'site_id' => 20]
 		];
 		$this->assertEquals($row, $result);
@@ -602,7 +602,7 @@ class HasManyTest extends \Cake\TestSuite\TestCase {
  * key will work if the foreign key is passed
  *
  * @expectedException \RuntimeException
- * @expectedExceptionMessage Cannot match provided foreignKey, got 1 columns expected 2
+ * @expectedExceptionMessage Cannot match provided foreignKey for "Articles", got "(author_id)" but expected foreign key for "(id, site_id)
  * @return void
  */
 	public function testAttachToMultiPrimaryKeyMistmatch() {

+ 1 - 1
tests/TestCase/ORM/Association/HasOneTest.php

@@ -256,7 +256,7 @@ class HasOneTest extends \Cake\TestSuite\TestCase {
  * key will work if the foreign key is passed
  *
  * @expectedException \RuntimeException
- * @expectedExceptionMessage Cannot match provided foreignKey, got 1 columns expected 2
+ * @expectedExceptionMessage Cannot match provided foreignKey for "Profiles", got "(user_id)" but expected foreign key for "(id, site_id)"
  * @return void
  */
 	public function testAttachToMultiPrimaryKeyMistmatch() {

+ 39 - 1
tests/TestCase/ORM/QueryRegressionTest.php

@@ -30,7 +30,7 @@ class QueryRegressionTest extends TestCase {
  *
  * @var array
  */
-	public $fixtures = ['core.user', 'core.article', 'core.tag', 'core.articles_tag'];
+	public $fixtures = ['core.user', 'core.article', 'core.tag', 'core.articles_tag', 'core.author'];
 
 /**
  * Tear down
@@ -66,4 +66,42 @@ class QueryRegressionTest extends TestCase {
 		$this->assertEmpty($results);
 	}
 
+/**
+ * Tests that duplicate aliases in contain() can be used, even when they would
+ * naturally be attached to the query instead of eagerly loaded. What should
+ * happen here is that One of the duplicates will be changed to be loaded using
+ * an extra query, but yielding the same results
+ *
+ * @return void
+ */
+	public function testDuplicateAttachableAliases() {
+		TableRegistry::get('Stuff', ['table' => 'tags']);
+		TableRegistry::get('Things', ['table' => 'articles_tags']);
+
+		$table = TableRegistry::get('Articles');
+		$table->belongsTo('Authors');
+		$table->hasOne('Things', ['propertyName' => 'articles_tag']);
+		$table->Authors->target()->hasOne('Stuff', [
+			'foreignKey' => 'id',
+			'propertyName' => 'favorite_tag'
+		]);
+		$table->Things->target()->belongsTo('Stuff', [
+			'foreignKey' => 'tag_id',
+			'propertyName' => 'foo']
+		);
+
+		$results = $table->find()
+			->contain(['Authors.Stuff', 'Things.Stuff'])
+			->toArray();
+
+		$this->assertEquals(1, $results[0]->articles_tag->foo->id);
+		$this->assertEquals(1, $results[0]->author->favorite_tag->id);
+		$this->assertEquals(2, $results[1]->articles_tag->foo->id);
+		$this->assertEquals(1, $results[0]->author->favorite_tag->id);
+		$this->assertEquals(1, $results[2]->articles_tag->foo->id);
+		$this->assertEquals(3, $results[2]->author->favorite_tag->id);
+		$this->assertEquals(3, $results[3]->articles_tag->foo->id);
+		$this->assertEquals(3, $results[3]->author->favorite_tag->id);
+	}
+
 }

+ 19 - 0
tests/TestCase/ORM/QueryTest.php

@@ -1928,4 +1928,23 @@ class QueryTest extends TestCase {
 		$this->assertNotEmpty($results[2]['article']);
 	}
 
+/**
+ * Tests that it is not allowed to use matching on an association
+ * that is already added to containments.
+ *
+ * @expectedException RuntimeException
+ * @expectedExceptionMessage Cannot use "matching" on "Authors" as there is another association with the same alias
+ * @return void
+ */
+	public function testConflitingAliases() {
+		$table = TableRegistry::get('ArticlesTags');
+		$table->belongsTo('Articles')->target()->belongsTo('Authors');
+		$table->belongsTo('Tags');
+		$table->Tags->target()->hasOne('Authors');
+		$table->find()
+			->contain(['Articles.Authors'])
+			->matching('Tags.Authors')
+			->all();
+	}
+
 }