diff --git a/src/ORM/Association.php b/src/ORM/Association.php index e24210e37e0..fe3ee7b00a2 100644 --- a/src/ORM/Association.php +++ b/src/ORM/Association.php @@ -1026,6 +1026,15 @@ abstract public function type(); */ abstract public function eagerLoader(array $options); + /** + * Returns true if the eager loading process will require a set of the owning table's + * binding keys in order to use them as a filter in the finder query. + * + * @param array $options The options containing the strategy to be used. + * @return bool true if a list of keys will be required + */ + abstract function requiresKeys(array $options = []); + /** * Handles cascading a delete from an associated model. * diff --git a/src/ORM/Association/BelongsToMany.php b/src/ORM/Association/BelongsToMany.php index d4c2fcc620a..05f8d4dc180 100644 --- a/src/ORM/Association/BelongsToMany.php +++ b/src/ORM/Association/BelongsToMany.php @@ -37,7 +37,6 @@ class BelongsToMany extends Association { use ExternalAssociationTrait { - _options as _externalOptions; _buildQuery as _buildBaseQuery; } @@ -1365,7 +1364,6 @@ protected function _junctionTableName($name = null) */ protected function _options(array $opts) { - $this->_externalOptions($opts); if (!empty($opts['targetForeignKey'])) { $this->targetForeignKey($opts['targetForeignKey']); } @@ -1378,5 +1376,8 @@ protected function _options(array $opts) if (!empty($opts['saveStrategy'])) { $this->saveStrategy($opts['saveStrategy']); } + if (isset($opts['sort'])) { + $this->sort($opts['sort']); + } } } diff --git a/src/ORM/Association/ExternalAssociationTrait.php b/src/ORM/Association/ExternalAssociationTrait.php index 385f9f881a8..4cc171150ea 100644 --- a/src/ORM/Association/ExternalAssociationTrait.php +++ b/src/ORM/Association/ExternalAssociationTrait.php @@ -123,17 +123,4 @@ protected function _buildResultMap($fetchQuery, $options) return $resultMap; } - - /** - * Parse extra options passed in the constructor. - * - * @param array $opts original list of options passed in constructor - * @return void - */ - protected function _options(array $opts) - { - if (isset($opts['sort'])) { - $this->sort($opts['sort']); - } - } } diff --git a/src/ORM/Association/HasMany.php b/src/ORM/Association/HasMany.php index 398c20ae669..2f482e9cca0 100644 --- a/src/ORM/Association/HasMany.php +++ b/src/ORM/Association/HasMany.php @@ -35,9 +35,7 @@ class HasMany extends Association { use DependentDeleteTrait; - use ExternalAssociationTrait { - _options as _externalOptions; - } + use ExternalAssociationTrait; /** * The type of join to be used when adding the association to a query @@ -534,9 +532,11 @@ public function type() */ protected function _options(array $opts) { - $this->_externalOptions($opts); if (!empty($opts['saveStrategy'])) { $this->saveStrategy($opts['saveStrategy']); } + if (isset($opts['sort'])) { + $this->sort($opts['sort']); + } } } diff --git a/src/ORM/Association/HasOne.php b/src/ORM/Association/HasOne.php index 4eea06dbafa..f1d5abcbac6 100644 --- a/src/ORM/Association/HasOne.php +++ b/src/ORM/Association/HasOne.php @@ -16,6 +16,7 @@ use Cake\Datasource\EntityInterface; use Cake\ORM\Association; +use Cake\ORM\Association\Loader\SelectLoader; use Cake\ORM\Table; use Cake\Utility\Inflector; @@ -29,7 +30,6 @@ class HasOne extends Association { use DependentDeleteTrait; - use SelectableAssociationTrait; /** * Valid strategies for this type of association @@ -128,41 +128,31 @@ public function saveAssociated(EntityInterface $entity, array $options = []) return $entity; } - /** - * {@inheritDoc} - */ - protected function _linkField($options) - { - $links = []; - $name = $this->alias(); - - foreach ((array)$options['foreignKey'] as $key) { - $links[] = sprintf('%s.%s', $name, $key); - } - - if (count($links) === 1) { - return $links[0]; - } - - return $links; + public function eagerLoader(array $options) { + $loader = new SelectLoader([ + 'alias' => $this->alias(), + 'sourceAlias' => $this->source()->alias(), + 'targetAlias' => $this->target()->alias(), + 'foreignKey' => $this->foreignKey(), + 'bindingKey' => $this->bindingKey(), + 'strategy' => $this->strategy(), + 'associationType' => $this->type(), + 'finder' => [$this, 'find'] + ]); + return $loader->buildLoadingQuery($options); } /** - * {@inheritDoc} + * Returns true if the eager loading process will require a set of the owning table's + * binding keys in order to use them as a filter in the finder query. + * + * @param array $options The options containing the strategy to be used. + * @return bool true if a list of keys will be required */ - protected function _buildResultMap($fetchQuery, $options) + public function requiresKeys(array $options = []) { - $resultMap = []; - $key = (array)$options['foreignKey']; - - foreach ($fetchQuery->all() as $result) { - $values = []; - foreach ($key as $k) { - $values[] = $result[$k]; - } - $resultMap[implode(';', $values)] = $result; - } + $strategy = isset($options['strategy']) ? $options['strategy'] : $this->strategy(); - return $resultMap; + return $strategy === $this::STRATEGY_SELECT; } } diff --git a/src/ORM/Association/Loader/SelectLoader.php b/src/ORM/Association/Loader/SelectLoader.php new file mode 100644 index 00000000000..1c28dc013c0 --- /dev/null +++ b/src/ORM/Association/Loader/SelectLoader.php @@ -0,0 +1,431 @@ +alias = $options['alias']; + $this->sourceAlias = $options['sourceAlias']; + $this->targetAlias = $options['targetAlias']; + $this->foreignKey = $options['foreignKey']; + $this->strategy = $options['strategy']; + $this->bindingKey = $options['bindingKey']; + $this->finder = $options['finder']; + $this->associationType = $options['associationType']; + } + + + public function buildLoadingQuery(array $options) + { + $options += $this->_defaultOptions(); + $fetchQuery = $this->_buildQuery($options); + $resultMap = $this->_buildResultMap($fetchQuery, $options); + + return $this->_resultInjector($fetchQuery, $resultMap, $options); + } + + /** + * Returns the default options to use for the eagerLoader + * + * @return array + */ + protected function _defaultOptions() + { + return [ + 'foreignKey' => $this->foreignKey, + 'conditions' => [], + 'strategy' => $this->strategy, + 'nestKey' => $this->alias + ]; + } + + /** + * Auxiliary function to construct a new Query object to return all the records + * in the target table that are associated to those specified in $options from + * the source table + * + * @param array $options options accepted by eagerLoader() + * @return \Cake\ORM\Query + * @throws \InvalidArgumentException When a key is required for associations but not selected. + */ + protected function _buildQuery($options) + { + $alias = $this->targetAlias; + $key = $this->_linkField($options); + $filter = $options['keys']; + $useSubquery = $options['strategy'] === Association::STRATEGY_SUBQUERY; + $finder = $this->finder; + + if (!isset($options['fields'])) { + $options['fields'] = []; + } + + $fetchQuery = $finder() + ->select($options['fields']) + ->where($options['conditions']) + ->eagerLoaded(true) + ->hydrate($options['query']->hydrate()); + + if ($useSubquery) { + $filter = $this->_buildSubquery($options['query']); + $fetchQuery = $this->_addFilteringJoin($fetchQuery, $key, $filter); + } else { + $fetchQuery = $this->_addFilteringCondition($fetchQuery, $key, $filter); + } + + if (!empty($options['sort'])) { + $fetchQuery->order($options['sort']); + } + + if (!empty($options['contain'])) { + $fetchQuery->contain($options['contain']); + } + + if (!empty($options['queryBuilder'])) { + $fetchQuery = $options['queryBuilder']($fetchQuery); + } + + $this->_assertFieldsPresent($fetchQuery, (array)$key); + + return $fetchQuery; + } + + /** + * Checks that the fetching query either has auto fields on or + * has the foreignKey fields selected. + * If the required fields are missing, throws an exception. + * + * @param \Cake\ORM\Query $fetchQuery The association fetching query + * @param array $key The foreign key fields to check + * @return void + * @throws InvalidArgumentException + */ + protected function _assertFieldsPresent($fetchQuery, $key) + { + $select = $fetchQuery->aliasFields($fetchQuery->clause('select')); + if (empty($select)) { + return; + } + $missingKey = function ($fieldList, $key) { + foreach ($key as $keyField) { + if (!in_array($keyField, $fieldList, true)) { + return true; + } + } + + return false; + }; + + $missingFields = $missingKey($select, $key); + if ($missingFields) { + $driver = $fetchQuery->connection()->driver(); + $quoted = array_map([$driver, 'quoteIdentifier'], $key); + $missingFields = $missingKey($select, $quoted); + } + + if ($missingFields) { + throw new InvalidArgumentException( + sprintf( + 'You are required to select the "%s" field(s)', + implode(', ', (array)$key) + ) + ); + } + } + + /** + * Appends any conditions required to load the relevant set of records in the + * target table query given a filter key and some filtering values when the + * filtering needs to be done using a subquery. + * + * @param \Cake\ORM\Query $query Target table's query + * @param string $key the fields that should be used for filtering + * @param \Cake\ORM\Query $subquery The Subquery to use for filtering + * @return \Cake\ORM\Query + */ + public function _addFilteringJoin($query, $key, $subquery) + { + $filter = []; + $aliasedTable = $this->sourceAlias; + + foreach ($subquery->clause('select') as $aliasedField => $field) { + if (is_int($aliasedField)) { + $filter[] = new IdentifierExpression($field); + } else { + $filter[$aliasedField] = $field; + } + } + $subquery->select($filter, true); + + if (is_array($key)) { + $conditions = $this->_createTupleCondition($query, $key, $filter, '='); + } else { + $filter = current($filter); + } + + $conditions = isset($conditions) ? $conditions : $query->newExpr([$key => $filter]); + + return $query->innerJoin( + [$aliasedTable => $subquery], + $conditions + ); + } + + /** + * Appends any conditions required to load the relevant set of records in the + * target table query given a filter key and some filtering values. + * + * @param \Cake\ORM\Query $query Target table's query + * @param string|array $key the fields that should be used for filtering + * @param mixed $filter the value that should be used to match for $key + * @return \Cake\ORM\Query + */ + protected function _addFilteringCondition($query, $key, $filter) + { + if (is_array($key)) { + $conditions = $this->_createTupleCondition($query, $key, $filter, 'IN'); + } + + $conditions = isset($conditions) ? $conditions : [$key . ' IN' => $filter]; + + return $query->andWhere($conditions); + } + + /** + * Returns a TupleComparison object that can be used for matching all the fields + * from $keys with the tuple values in $filter using the provided operator. + * + * @param \Cake\ORM\Query $query Target table's query + * @param array $keys the fields that should be used for filtering + * @param mixed $filter the value that should be used to match for $key + * @param string $operator The operator for comparing the tuples + * @return \Cake\Database\Expression\TupleComparison + */ + protected function _createTupleCondition($query, $keys, $filter, $operator) + { + $types = []; + $defaults = $query->defaultTypes(); + foreach ($keys as $k) { + if (isset($defaults[$k])) { + $types[] = $defaults[$k]; + } + } + + return new TupleComparison($keys, $filter, $types, $operator); + } + + /** + * Generates a string used as a table field that contains the values upon + * which the filter should be applied + * + * @param array $options The options for getting the link field. + * @return string|array + */ + protected function _linkField($options) + { + $links = []; + $name = $this->alias; + + foreach ((array)$options['foreignKey'] as $key) { + $links[] = sprintf('%s.%s', $name, $key); + } + + if (count($links) === 1) { + return $links[0]; + } + + return $links; + } + + /** + * Builds a query to be used as a condition for filtering records in the + * target table, it is constructed by cloning the original query that was used + * to load records in the source table. + * + * @param \Cake\ORM\Query $query the original query used to load source records + * @return \Cake\ORM\Query + */ + protected function _buildSubquery($query) + { + $filterQuery = clone $query; + $filterQuery->autoFields(false); + $filterQuery->mapReduce(null, null, true); + $filterQuery->formatResults(null, true); + $filterQuery->contain([], true); + $filterQuery->valueBinder(new ValueBinder()); + + if (!$filterQuery->clause('limit')) { + $filterQuery->limit(null); + $filterQuery->order([], true); + $filterQuery->offset(null); + } + + $fields = $this->_subqueryFields($query); + $filterQuery->select($fields['select'], true)->group($fields['group']); + + return $filterQuery; + } + + /** + * Calculate the fields that need to participate in a subquery. + * + * Normally this includes the binding key columns. If there is a an ORDER BY, + * those columns are also included as the fields may be calculated or constant values, + * that need to be present to ensure the correct association data is loaded. + * + * @param \Cake\ORM\Query $query The query to get fields from. + * @return array The list of fields for the subquery. + */ + protected function _subqueryFields($query) + { + $keys = (array)$this->bindingKey; + + if ($this->associationType === Association::MANY_TO_ONE) { + $keys = (array)$this->foreignKey; + } + + $fields = $query->aliasFields($keys, $this->sourceAlias); + $group = $fields = array_values($fields); + + $order = $query->clause('order'); + if ($order) { + $columns = $query->clause('select'); + $order->iterateParts(function ($direction, $field) use (&$fields, $columns) { + if (isset($columns[$field])) { + $fields[$field] = $columns[$field]; + } + }); + } + + return ['select' => $fields, 'group' => $group]; + } + + /** + * Builds an array containing the results from fetchQuery indexed by + * the foreignKey value corresponding to this association. + * + * @param \Cake\ORM\Query $fetchQuery The query to get results from + * @param array $options The options passed to the eager loader + * @return array + */ + protected function _buildResultMap($fetchQuery, $options) + { + $resultMap = []; + $key = (array)$options['foreignKey']; + + foreach ($fetchQuery->all() as $result) { + $values = []; + foreach ($key as $k) { + $values[] = $result[$k]; + } + $resultMap[implode(';', $values)] = $result; + } + + return $resultMap; + } + + /** + * Returns a callable to be used for each row in a query result set + * for injecting the eager loaded rows + * + * @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, $options) + { + $keys = $this->associationType === Association::MANY_TO_ONE ? + $this->foreignKey : + $this->bindingKey; + + $sourceKeys = []; + foreach ((array)$keys as $key) { + $f = $fetchQuery->aliasField($key, $this->sourceAlias); + $sourceKeys[] = key($f); + } + + $nestKey = $options['nestKey']; + if (count($sourceKeys) > 1) { + return $this->_multiKeysInjector($resultMap, $sourceKeys, $nestKey); + } + + $sourceKey = $sourceKeys[0]; + + return function ($row) use ($resultMap, $sourceKey, $nestKey) { + if (isset($row[$sourceKey], $resultMap[$row[$sourceKey]])) { + $row[$nestKey] = $resultMap[$row[$sourceKey]]; + } + + return $row; + }; + } + + /** + * 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 + * + * @param array $resultMap A keyed arrays containing the target table + * @param array $sourceKeys An array with aliased keys to match + * @param string $nestKey The key under which results should be nested + * @return \Closure + */ + protected function _multiKeysInjector($resultMap, $sourceKeys, $nestKey) + { + return function ($row) use ($resultMap, $sourceKeys, $nestKey) { + $values = []; + foreach ($sourceKeys as $key) { + $values[] = $row[$key]; + } + + $key = implode(';', $values); + if (isset($resultMap[$key])) { + $row[$nestKey] = $resultMap[$key]; + } + + return $row; + }; + } +} diff --git a/tests/TestCase/ORM/AssociationTest.php b/tests/TestCase/ORM/AssociationTest.php index b7069cbc05a..73d9ac5f873 100644 --- a/tests/TestCase/ORM/AssociationTest.php +++ b/tests/TestCase/ORM/AssociationTest.php @@ -63,7 +63,7 @@ public function setUp() $this->association = $this->getMockBuilder('\Cake\ORM\Association') ->setMethods([ '_options', 'attachTo', '_joinCondition', 'cascadeDelete', 'isOwningSide', - 'saveAssociated', 'eagerLoader', 'type' + 'saveAssociated', 'eagerLoader', 'type', 'requiresKeys' ]) ->setConstructorArgs(['Foo', $config]) ->getMock(); @@ -128,7 +128,7 @@ public function testClassNameUnnormalized() $this->association = $this->getMockBuilder('\Cake\ORM\Association') ->setMethods([ '_options', 'attachTo', '_joinCondition', 'cascadeDelete', 'isOwningSide', - 'saveAssociated', 'eagerLoader', 'type' + 'saveAssociated', 'eagerLoader', 'type', 'requiresKeys' ]) ->setConstructorArgs(['Foo', $config]) ->getMock(); @@ -263,7 +263,10 @@ public function testTargetPlugin() ]; $this->association = $this->getMockBuilder('\Cake\ORM\Association') - ->setMethods(['type', 'eagerLoader', 'cascadeDelete', 'isOwningSide', 'saveAssociated']) + ->setMethods([ + 'type', 'eagerLoader', 'cascadeDelete', 'isOwningSide', 'saveAssociated', + 'requiresKeys' + ]) ->setConstructorArgs(['ThisAssociationName', $config]) ->getMock(); @@ -358,7 +361,7 @@ public function testPropertyNameExplicitySet() $association = $this->getMockBuilder('\Cake\ORM\Association') ->setMethods([ '_options', 'attachTo', '_joinCondition', 'cascadeDelete', 'isOwningSide', - 'saveAssociated', 'eagerLoader', 'type' + 'saveAssociated', 'eagerLoader', 'type', 'requiresKeys' ]) ->setConstructorArgs(['Foo', $config]) ->getMock(); @@ -421,7 +424,10 @@ public function testFinderInConstructor() 'finder' => 'published' ]; $assoc = $this->getMockBuilder('\Cake\ORM\Association') - ->setMethods(['type', 'eagerLoader', 'cascadeDelete', 'isOwningSide', 'saveAssociated']) + ->setMethods([ + 'type', 'eagerLoader', 'cascadeDelete', 'isOwningSide', 'saveAssociated', + 'requiresKeys' + ]) ->setConstructorArgs(['Foo', $config]) ->getMock(); $this->assertEquals('published', $assoc->finder()); @@ -455,7 +461,10 @@ public function testLocatorInConstructor() 'tableLocator' => $locator ]; $assoc = $this->getMockBuilder('\Cake\ORM\Association') - ->setMethods(['type', 'eagerLoader', 'cascadeDelete', 'isOwningSide', 'saveAssociated']) + ->setMethods([ + 'type', 'eagerLoader', 'cascadeDelete', 'isOwningSide', 'saveAssociated', + 'requiresKeys' + ]) ->setConstructorArgs(['Foo', $config]) ->getMock(); $this->assertEquals($locator, $assoc->tableLocator());