Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 94 additions & 88 deletions models/behaviors/linkable.php → Model/Behavior/LinkableBehavior.php
Original file line number Diff line number Diff line change
@@ -1,195 +1,201 @@
<?php
/*
/**
* LinkableBehavior
* Light-weight approach for data mining on deep relations between models.
* Light-weight approach for data mining on deep relations between models.
* Join tables based on model relations to easily enable right to left find operations.
*
* Original behavior by rafaelbandeira3 on GitHub.
* Includes modifications from Terr, n8man, and Chad Jablonski
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* GiulianoB ( https://github.com/giulianob/linkable )
*
* https://github.com/Terr/linkable
*
* @version 1.0;
* @version 1.0;
*
* @version 1.1:
* -Brought in improvements and test cases from Terr. However, THIS VERSION OF LINKABLE IS NOT DROP IN COMPATIBLE WITH Terr's VERSION!
* -If fields aren't specified, will now return all columns of that model
* -No need to specify the foreign key condition if a custom condition is given. Linkable will automatically include the foreign key relationship.
* -Ability to specify the exact condition Linkable should use (e.g. $this->Post->find('first', array('link' => array('User' => array('conditions' => array('exactly' => 'User.last_post_id = Post.id'))))) )
* This is usually required when doing on-the-fly joins since Linkable generally assumes a belongsTo relationship when no specific relationship is found and may produce invalid foreign key conditions.
* -Linkable will no longer break queries that use SQL COUNTs
*
* @version 1.2:
* @modified Mark Scherer
* - works with cakephp2.0 (89.84 test coverage)
*/

class LinkableBehavior extends ModelBehavior {

protected $_key = 'link';

protected $_options = array(
'type' => true, 'table' => true, 'alias' => true,
'conditions' => true, 'fields' => true, 'reference' => true,
'class' => true, 'defaults' => true
);

protected $_defaults = array('type' => 'LEFT');

public function beforeFind(&$Model, $query) {
if (isset($query[$this->_key])) {


public function beforeFind(Model $Model, $query) {
if (isset($query[$this->_key])) {
$optionsDefaults = $this->_defaults + array('reference' => $Model->alias, $this->_key => array());
$optionsKeys = $this->_options + array($this->_key => true);


// If containable is being used, then let it set the recursive!
if (empty($query['contain'])) {
$query = am(array('joins' => array()), $query, array('recursive' => -1));
} else {
// If containable is being used, then let it set the recursive!
} else {
$query = am(array('joins' => array()), $query);
}

$iterators[] = $query[$this->_key];
$cont = 0;

do {
do {
$iterator = $iterators[$cont];
$defaults = $optionsDefaults;

if (isset($iterator['defaults'])) {
$defaults = array_merge($defaults, $iterator['defaults']);
unset($iterator['defaults']);
}

$iterations = Set::normalize($iterator);

foreach ($iterations as $alias => $options) {
if (is_null($options)) {
$options = array();
}

$options = am($defaults, compact('alias'), $options);

if (empty($options['alias'])) {
throw new InvalidArgumentException(sprintf('%s::%s must receive aliased links', get_class($this), __FUNCTION__));
}

}
if (empty($options['table']) && empty($options['class'])) {
$options['class'] = $options['alias'];
} elseif (!empty($options['table']) && empty($options['class'])) {
$options['class'] = Inflector::classify($options['table']);
}

$_Model =& ClassRegistry::init($options['class']); // the incoming model to be linked in query
$Reference =& ClassRegistry::init($options['reference']); // the already in query model that links to $_Model
$db =& $_Model->getDataSource();

// the incoming model to be linked in query
$_Model = ClassRegistry::init($options['class']);
// the already in query model that links to $_Model
$Reference = ClassRegistry::init($options['reference']);
$db = $_Model->getDataSource();
$associations = $_Model->getAssociated();

if (isset($Reference->belongsTo[$_Model->alias])) {
$type = 'hasOne';
$association = $Reference->belongsTo[$_Model->alias];
$association = $Reference->belongsTo[$_Model->alias];
} else if (isset($associations[$Reference->alias])) {
$type = $associations[$Reference->alias];
$association = $_Model->{$type}[$Reference->alias];
$association = $_Model->{$type}[$Reference->alias];
} else {
$_Model->bindModel(array('belongsTo' => array($Reference->alias)));
$type = 'belongsTo';
$association = $_Model->{$type}[$Reference->alias];
$_Model->unbindModel(array('belongsTo' => array($Reference->alias)));
}

if (empty($options['conditions'])) {

if (!isset($options['conditions'])) {
$options['conditions'] = array();
} else if (!is_array($options['conditions'])) {
// Support for string conditions
$options['conditions'] = array($options['conditions']);
}

if (isset($options['conditions']['exactly'])) {
if (is_array($options['conditions']['exactly']))
$options['conditions'] = reset($options['conditions']['exactly']);
else
$options['conditions'] = array($options['conditions']['exactly']);
} else {
if ($type === 'belongsTo') {
$modelKey = $_Model->escapeField($association['foreignKey']);
$modelKey = str_replace($_Model->alias, $options['alias'], $modelKey);
$referenceKey = $Reference->escapeField($Reference->primaryKey);
$options['conditions'] = "{$referenceKey} = {$modelKey}";
$options['conditions'][] = "{$referenceKey} = {$modelKey}";
} elseif ($type === 'hasAndBelongsToMany') {
if (isset($association['with'])) {
$Link =& $_Model->{$association['with']};

$Link = $_Model->{$association['with']};
if (isset($Link->belongsTo[$_Model->alias])) {
$modelLink = $Link->escapeField($Link->belongsTo[$_Model->alias]['foreignKey']);
}

if (isset($Link->belongsTo[$Reference->alias])) {
$referenceLink = $Link->escapeField($Link->belongsTo[$Reference->alias]['foreignKey']);
}
}
} else {
$Link =& $_Model->{Inflector::classify($association['joinTable'])};
$Link = $_Model->{Inflector::classify($association['joinTable'])};
}

if (empty($modelLink)) {
$modelLink = $Link->escapeField(Inflector::underscore($_Model->alias) . '_id');
}

if (empty($referenceLink)) {
$referenceLink = $Link->escapeField(Inflector::underscore($Reference->alias) . '_id');
}

$referenceKey = $Reference->escapeField();
$query['joins'][] = array(
'alias' => $Link->alias,
'table' => $Link->getDataSource()->fullTableName($Link),
'table' => $Link->table, //$Link->getDataSource()->fullTableName($Link),
'conditions' => "{$referenceLink} = {$referenceKey}",
'type' => 'LEFT'
);

$modelKey = $_Model->escapeField();
$options['conditions'] = "{$modelLink} = {$modelKey}";
$modelKey = str_replace($_Model->alias, $options['alias'], $modelKey);
$options['conditions'][] = "{$modelLink} = {$modelKey}";
} else {
$referenceKey = $Reference->escapeField($association['foreignKey']);
$modelKey = $_Model->escapeField($_Model->primaryKey);
$options['conditions'] = "{$modelKey} = {$referenceKey}";
$modelKey = str_replace($_Model->alias, $options['alias'], $modelKey);
$options['conditions'][] = "{$modelKey} = {$referenceKey}";
}
}

if (empty($options['table'])) {
$options['table'] = $db->fullTableName($_Model, true);
$options['table'] = $_Model->table;
}

if (!empty($options['fields'])) {
if ($options['fields'] === true && !empty($association['fields'])) {
$options['fields'] = $db->fields($_Model, null, $association['fields']);
} elseif ($options['fields'] === true) {
$options['fields'] = $db->fields($_Model);
}
// Leave COUNT() queries alone
elseif($options['fields'] != 'COUNT(*) AS `count`')
{
$options['fields'] = $db->fields($_Model, null, $options['fields']);
}

if (is_array($query['fields']))
{
$query['fields'] = array_merge($query['fields'], $options['fields']);

// Decide whether we should mess with the fields or not
// If this query is a COUNT query then we just leave it alone
if (!isset($query['fields']) || is_array($query['fields']) || strpos($query['fields'], 'COUNT(*)') === FALSE) {
if (!empty($options['fields'])) {
if ($options['fields'] === true && !empty($association['fields'])) {
$options['fields'] = $db->fields($_Model, null, $association['fields']);
} elseif ($options['fields'] === true) {
$options['fields'] = $db->fields($_Model);
} else {
$options['fields'] = $db->fields($_Model, null, $options['fields']);
}
}
// Leave COUNT() queries alone
elseif($query['fields'] != 'COUNT(*) AS `count`')
else if (!isset($options['fields']) || (isset($options['fields']) && !is_array($options['fields'])))
{
$query['fields'] = array_merge($db->fields($Model), $options['fields']);
if (!empty($association['fields'])) {
$options['fields'] = $db->fields($_Model, null, $association['fields']);
} else {
$options['fields'] = $db->fields($_Model);
}
}
}
else
{
if (!empty($association['fields'])) {
$options['fields'] = $db->fields($_Model, null, $association['fields']);
} else {
$options['fields'] = $db->fields($_Model);

if (!empty($options['class']) && $options['class'] !== $alias) {
$options['fields'] = str_replace($options['class'], $alias, $options['fields']);
}

if (is_array($query['fields'])) {
$query['fields'] = array_merge($query['fields'], $options['fields']);
} // Leave COUNT() queries alone
elseif($query['fields'] != 'COUNT(*) AS `count`') {
} else {
// If user didn't specify any fields then select all fields by default (just as find would)
$query['fields'] = array_merge($db->fields($Model), $options['fields']);
}
}

$options[$this->_key] = am($options[$this->_key], array_diff_key($options, $optionsKeys));
$options = array_intersect_key($options, $optionsKeys);

if (!empty($options[$this->_key])) {
$iterators[] = $options[$this->_key] + array('defaults' => array_merge($defaults, array('reference' => $options['class'])));
}

$options['conditions'] = array($options['conditions']);
$query['joins'][] = array_intersect_key($options, array('type' => true, 'alias' => true, 'table' => true, 'conditions' => true));

$query['joins'][] = array_intersect_key($options, array('type' => true, 'alias' => true, 'table' => true, 'conditions' => true));
}

$cont++;
$notDone = isset($iterators[$cont]);
} while ($notDone);
}
}

unset($query['link']);

return $query;
}
}
}
66 changes: 66 additions & 0 deletions README.markdown
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
### Linkable Plugin
CakePHP Plugin - PHP 5 only

LinkableBehavior
Light-weight approach for data mining on deep relations between models.
Join tables based on model relations to easily enable right to left find operations.

Original behavior by rafaelbandeira3 on GitHub. Includes modifications from Terr, n8man, and Chad Jablonski

Licensed under The MIT License
Redistributions of files must retain the above copyright notice.

This version is maintaned by:
GiulianoB ( https://bitbucket.org/giulianob/linkable/ )

### version 1.1:
- Brought in improvements and test cases from Terr. However, THIS VERSION OF LINKABLE IS NOT DROP IN COMPATIBLE WITH Terr's VERSION!
- If fields aren't specified, will now return all columns of that model
- No need to specify the foreign key condition if a custom condition is given. Linkable will automatically include the foreign key relationship.
- Ability to specify the exact condition Linkable should use. This is usually required when doing on-the-fly joins since Linkable generally assumes a belongsTo relationship when no specific relationship is found and may produce invalid foreign key conditions. Example:

$this->Post->find('first', array('link' => array('User' => array('conditions' => array('exactly' => 'User.last_post_id = Post.id')))))

- Linkable will no longer break queries that use SQL COUNTs

### Complex Example

Here's a complex example using both linkable and containable at the same time :)

Relationships involved:
CasesRun is the HABTM table of TestRun <-> TestCases
CasesRun belongsTo TestRun
CasesRun belongsTo User
CasesRun belongsTo TestCase
TestCase belongsTo TestSuite
TestSuite belongsTo TestHarness
CasesRun HABTM Tags

$this->TestRun->CasesRun->find('all', array(
'link' => array(
'User' => array('fields' => 'username'),
'TestCase' => array('fields' => array('TestCase.automated', 'TestCase.name'),
'TestSuite' => array('fields' => array('TestSuite.name'),
'TestHarness' => array('fields' => array('TestHarness.name'))
)
)
),
'conditions' => array('test_run_id' => $id),
'contain' => array(
'Tag'
),
'fields' => array(
'CasesRun.id', 'CasesRun.state', 'CasesRun.modified', 'CasesRun.comments'
)
))

Output SQL:

SELECT `CasesRun`.`id`, `CasesRun`.`state`, `CasesRun`.`modified`, `CasesRun`.`comments`, `User`.`username`, `TestCase`.`automated`, `TestCase`.`name`, `TestSuite`.`name`, `TestHarness`.`name` FROM `cases_runs` AS `CasesRun` LEFT JOIN `users` AS `User` ON (`User`.`id` = `CasesRun`.`user_id`) LEFT JOIN `test_cases` AS `TestCase` ON (`TestCase`.`id` = `CasesRun`.`test_case_id`) LEFT JOIN `test_suites` AS `TestSuite` ON (`TestSuite`.`id` = `TestCase`.`test_suite_id`) LEFT JOIN `test_harnesses` AS `TestHarness` ON (`TestHarness`.`id` = `TestSuite`.`test_harness_id`) WHERE `test_run_id` = 32

SELECT `Tag`.`id`, `Tag`.`name`, `CasesRunsTag`.`id`, `CasesRunsTag`.`cases_run_id`, `CasesRunsTag`.`tag_id` FROM `tags` AS `Tag` JOIN `cases_runs_tags` AS `CasesRunsTag` ON (`CasesRunsTag`.`cases_run_id` IN (345325, 345326, 345327, 345328) AND `CasesRunsTag`.`tag_id` = `Tag`.`id`) WHERE 1 = 1

If you were to try this example with containable, you would find that it generates a lot of queries to fetch all of the data records. Linkable produces a single query with joins instead.

### More examples
Look into the unit tests for some more ways of using Linkable
Loading