Skip to content

Commit

Permalink
Implementing subquery filtering for HasMany associations and setting it
Browse files Browse the repository at this point in the history
to be used by default
  • Loading branch information
lorenzo committed May 20, 2013
1 parent eb27c06 commit cc4be72
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 20 deletions.
53 changes: 53 additions & 0 deletions lib/Cake/ORM/Association.php
Expand Up @@ -24,6 +24,27 @@
*/
abstract class Association {

/**
* Strategy name to use joins for fetching associated records
*
* @var string
*/
const STRATEGY_JOIN = 'join';

/**
* Strategy name to use a subquery for fetching associated records
*
* @var string
*/
const STRATEGY_SUBQUERY = 'subquery';

/**
* Strategy name to use a select for fetching associated records
*
* @var string
*/
const STRATEGY_SELECT = 'select';

/**
* Name given to the association, it usually represents the alias
* assigned to the target associated table
Expand Down Expand Up @@ -99,6 +120,14 @@ abstract class Association {
*/
protected $_property;

/**
* The strategy name to be used to fetch associated records. Some association
* types might not implement but one strategy to fetch records.
*
* @var string
*/
protected $_strategy = self::STRATEGY_JOIN;

/**
* Constructor. Subclasses can override _options function to get the original
* list of passed options if expecting any other special key
Expand Down Expand Up @@ -134,6 +163,10 @@ public function __construct($name, array $options = []) {
if (empty($this->_property)) {
$this->property($name);
}

if (!empty($options['strategy'])) {
$this->strategy($options['strategy']);
}
}

/**
Expand Down Expand Up @@ -262,6 +295,7 @@ public function joinType($type = null) {
* in the source table record.
* If no arguments are passed, currently configured type is returned.
*
* @param string $name
* @return string
*/
function property($name = null) {
Expand All @@ -271,6 +305,25 @@ function property($name = null) {
return $this->_property;
}

/**
* Sets the strategy name to be used to fetch associated records. Keep in mind
* that some association types might not implement but a default strategy,
* rendering any changes to this setting void.
* If no arguments are passed, currently configured strategy is returned.
*
* @param string $name
* @return string
*/
function strategy($name = null) {
if ($name !== null) {
$valid = [self::STRATEGY_JOIN, self::STRATEGY_SELECT, self::STRATEGY_SUBQUERY];
if (in_array($name, $valid)) {
$this->_strategy = $name;
}
}
return $this->_strategy;
}

/**
* Override this function to initialize any concrete association class, it will
* get passed the original list of options used in the constructor
Expand Down
52 changes: 46 additions & 6 deletions lib/Cake/ORM/Association/HasMany.php
Expand Up @@ -46,6 +46,14 @@ class HasMany extends Association {
*/
protected $_sort;

/**
* The strategy name to be used to fetch associated records. Some association
* types might not implement but one strategy to fetch records.
*
* @var string
*/
protected $_strategy = parent::STRATEGY_SUBQUERY;

/**
* Sets the name of the field representing the foreign key to the target table.
* If no parameters are passed current field is returned
Expand Down Expand Up @@ -80,13 +88,19 @@ public function attachTo(Query $query, array $options = []) {
return false;
}

public function eagerLoader($parentKeys, $options = []) {
public function requiresKeys($options = []) {
$strategy = isset($options['strategy']) ? $options['strategy'] : $this->strategy();
return $strategy !== parent::STRATEGY_SUBQUERY;
}

public function eagerLoader($parentQuery, $options = [], $parentKeys = null) {
$options += [
'foreignKey' => $this->foreignKey(),
'conditions' => [],
'sort' => $this->sort()
'sort' => $this->sort(),
'strategy' => $this->strategy()
];
$fetchQuery = $this->_buildQuery($parentKeys, $options);
$fetchQuery = $this->_buildQuery($parentQuery, $options, $parentKeys);
$resultMap = [];
$key = $options['foreignKey'];
foreach ($fetchQuery->execute() as $result) {
Expand All @@ -108,15 +122,19 @@ public function eagerLoader($parentKeys, $options = []) {
};
}

protected function _buildQuery($parentKeys, $options) {
protected function _buildQuery($parentQuery, $options, $parentKeys = null) {
$target = $this->target();
$alias = $target->alias();
$fetchQuery = $target->find('all');
$options['conditions'] = array_merge($this->conditions(), $options['conditions']);
$key = sprintf('%s.%s in', $alias, $options['foreignKey']);
$key = sprintf('%s.%s', $alias, $options['foreignKey']);

$filter = ($options['strategy'] == parent::STRATEGY_SUBQUERY) ?
$this->_buildSubquery($parentQuery, $key) : $parentKeys;

$fetchQuery
->where($options['conditions'])
->andWhere([$key => $parentKeys]);
->andWhere([$key . ' in' => $filter]);

if (!empty($options['fields'])) {
$fields = $fetchQuery->aliasFields($options['fields'], $alias);
Expand Down Expand Up @@ -152,4 +170,26 @@ protected function _options(array $opts) {
}
}

/**
* Builds a query to be used as a condition for filtering records in 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
* @param strong $foreignKey the field to be selected in the query
* @return Cake\ORM\Query
*/
protected function _buildSubquery($query, $foreignKey) {
$filterQuery = clone $query;
$filterQuery->contain([], true);
$joins = $filterQuery->join();
foreach ($joins as $i => $join) {
if (strtolower($join['type']) !== 'inner') {
unset($joins[$i]);
}
}
$filterQuery->join($joins, [], true);
return $filterQuery->select($foreignKey, true);
}

}
14 changes: 8 additions & 6 deletions lib/Cake/ORM/Query.php
Expand Up @@ -42,8 +42,9 @@ public function addDefaultTypes(Table $table) {
$this->defaultTypes($this->defaultTypes() + $fields);
}

public function contain($associations = null) {
if ($this->_containments === null) {
public function contain($associations = null, $override = false) {
if ($this->_containments === null || $override) {
$this->_dirty = true;
$this->_containments = new \ArrayObject;
}
if ($associations === null) {
Expand All @@ -57,6 +58,7 @@ public function contain($associations = null) {
}
$this->_containments[$table] = $options;
}
$this->_dirty = true;
return $this;
}

Expand Down Expand Up @@ -162,8 +164,7 @@ protected function _addContainments() {
}
}

$firstLevelJoins = $this->_resolveFirstLevel($this->_table, $contain);
foreach ($firstLevelJoins as $options) {
foreach ($this->_resolveFirstLevel($this->_table, $contain) as $options) {
$table = $options['instance']->target();
$alias = $table->alias();
$this->_aliasMap[$alias] = $table;
Expand Down Expand Up @@ -243,8 +244,9 @@ protected function _eagerLoad($statement) {
foreach ($this->_loadEagerly as $association => $meta) {
$contain = $meta['associations'];
$f = $meta['instance']->eagerLoader(
$keys[$meta['instance']->source()->alias()],
$meta['config'] + compact('contain')
$this,
$meta['config'] + compact('contain'),
$keys[$meta['instance']->source()->alias()]
);
$statement = new CallbackStatement($statement, $this->connection()->driver(), $f);
}
Expand Down
20 changes: 12 additions & 8 deletions lib/Cake/Test/TestCase/ORM/Association/HasManyTest.php
Expand Up @@ -87,7 +87,8 @@ public function testSort() {
public function testEagerLoader() {
$config = [
'sourceTable' => $this->author,
'targetTable' => $this->article
'targetTable' => $this->article,
'strategy' => 'select'
];
$association = new HasMany('Article', $config);
$keys = [1, 2, 3, 4];
Expand All @@ -101,7 +102,7 @@ public function testEagerLoader() {
$query->expects($this->once())->method('execute')
->will($this->returnValue($results));

$callable = $association->eagerLoader($keys);
$callable = $association->eagerLoader(null, [], $keys);
$row = ['Author__id' => 1, 'username' => 'author 1'];
$result = $callable($row);
$row['Author__Article'] = [
Expand All @@ -127,7 +128,8 @@ public function testEagerLoaderWithDefaults() {
'sourceTable' => $this->author,
'targetTable' => $this->article,
'conditions' => ['Article.is_active' => true],
'sort' => ['id' => 'ASC']
'sort' => ['id' => 'ASC'],
'strategy' => 'select'
];
$association = new HasMany('Article', $config);
$keys = [1, 2, 3, 4];
Expand Down Expand Up @@ -158,7 +160,7 @@ public function testEagerLoaderWithDefaults() {
->with(['id' => 'ASC'])
->will($this->returnValue($query));

$association->eagerLoader($keys);
$association->eagerLoader(null, [], $keys);
}

/**
Expand All @@ -171,7 +173,8 @@ public function testEagerLoaderWithOverrides() {
'sourceTable' => $this->author,
'targetTable' => $this->article,
'conditions' => ['Article.is_active' => true],
'sort' => ['id' => 'ASC']
'sort' => ['id' => 'ASC'],
'strategy' => 'select'
];
$association = new HasMany('Article', $config);
$keys = [1, 2, 3, 4];
Expand Down Expand Up @@ -219,12 +222,12 @@ public function testEagerLoaderWithOverrides() {
])
->will($this->returnValue($query));

$association->eagerLoader($keys, [
$association->eagerLoader(null, [
'conditions' => ['Article.id !=' => 3],
'sort' => ['title' => 'DESC'],
'fields' => ['title', 'author_id'],
'contain' => ['Category' => ['fields' => ['a', 'b']]]
]);
], $keys);
}

/**
Expand All @@ -239,6 +242,7 @@ public function testEagerLoaderFieldsException() {
$config = [
'sourceTable' => $this->author,
'targetTable' => $this->article,
'strategy' => 'select'
];
$association = new HasMany('Article', $config);
$keys = [1, 2, 3, 4];
Expand All @@ -250,7 +254,7 @@ public function testEagerLoaderFieldsException() {
$this->article->expects($this->once())->method('find')->with('all')
->will($this->returnValue($query));

$association->eagerLoader($keys, ['fields' => ['id', 'title']]);
$association->eagerLoader(null, ['fields' => ['id', 'title']], $keys);
}

}
28 changes: 28 additions & 0 deletions lib/Cake/Test/TestCase/ORM/AssociationTest.php
Expand Up @@ -158,4 +158,32 @@ public function testJoinType() {
$this->assertEquals('LEFT', $this->association->joinType());
}

/**
* Tests property method
*
* @return void
*/
public function testProperty() {
$this->assertEquals('Foo', $this->association->property());
$this->association->property('thing');
$this->assertEquals('thing', $this->association->property());
}

/**
* Tests strategy method
*
* @return void
*/
public function testStrategy() {
$this->assertEquals('join', $this->association->strategy());
$this->association->strategy('thing');
$this->assertEquals('join', $this->association->strategy());
$this->association->strategy('select');
$this->assertEquals('select', $this->association->strategy());
$this->association->strategy('subquery');
$this->assertEquals('subquery', $this->association->strategy());
$this->association->strategy('anotherThing');
$this->assertEquals('subquery', $this->association->strategy());
}

}

0 comments on commit cc4be72

Please sign in to comment.