A behavior for CakePHP 2.x
Background
I stumbled upon this fork and SimpleScope.
The latter has the disadvantage of redundancy in the scope conditions when used in multiple find configs. The first was pretty much how Rails’ scopes work.
But amongst other small issues it lacked the possibility of using model attributes for configuration.
And both didn’t have test cases.
So I decided to combine both, test the hell out of them and get the best out of the basic implementation ideas.
Basic Usage
For the behavior there is a more detailed documentation in the wiki.
But here it goes.
First install/download and load the Tools plugin as documented in the cookbook or its readme file.
Attach the behavior to your AppModel:
App::uses('Model', 'Model');
class AppModel extends Model {
public $actsAs = array('Tools.NamedScope');
}
Then define some scopes in your model:
App::uses('AppModel', 'Model');
class User extends AppModel {
public $scopes = array(
'active' => array('User.active' => 1),
'admin' => array('User.role LIKE' => '%admin%'),
);
}
Then you can use those scopes in any of your find queries:
$activeUsers = $this->User->find('all', array('scope' => array('active')));
$activeAdmins = $this->User->find('all', array('scope' => array('active', 'admin')));
$activeAdminList = $this->User->find('list', array('scope' => array('active', 'admin')));
Advanced Usage
If you also want to use scopedFind(), you will also get rid of all the many find wrappers around those scopes that will often be placed inside the models.
An example:
public function getActiveAdmins() {
$this->virtualFields['fullname'] = "CONCAT(User.firstname, ' ', User.lastname)";
$options = array(
'fields' => array('User.id', 'User.fullname'),
'conditions' => array('User.role LIKE' => '%admin%'),
'order' => array('User.fullname'),
);
return $this->find('all', $options);
}
Now there will maybe also be a getActiveUsers() method and maybe a few dozen more, which all
contain the same condition – which is not really DRY and might be quite error-prone if the conditions have to be adjusted (easy to miss one of the many occurrences in and out of the model).
So what would be a smarter way to approach this?
Let’s try to use the above scopes here – and also use the single wrapper method.
Besides the above scopes, you also need to define some scopedFinds in your model:
App::uses('AppModel', 'Model');
class User extends AppModel {
public $scopes = array(
'activeAdmins' => array(
'name' => 'Active admin users',
'find' => array(
'type' => 'all',
'virtualFields' => array(
'fullname' => "CONCAT(User.firstname, ' ', User.lastname)"
),
'options' => array(
'fields' => array('User.id', 'User.fullname'),
'scope' => array('active', 'admin'),
'order' => array('User.fullname'),
),
),
),
'activeUsers' => array(
...
)
);
}
The scope itself will both contain active
, and the config about this scope key will be stored in a single place. So if you have some very complex condition around published (> a && < b && != c && …) this will take the overhead from multiple definitions and reduce it to a single location.
Let’s execute it:
$activeAdmins = $this->User->scopedFind('activeAdmins');
In case we need to only get a list or the count, we can adjust the scopedFind:
$activeAdminList = $this->User->scopedFind('activeAdmins', array('type' => 'list'));
$activeAdminCount = $this->User->scopedFind('activeAdmins', array('type' => 'count'));
We can also overwrite the default options:
$config = array(
'options' => array(
'limit' => 2,
'order' => array('User.created' => 'DESC'))
);
$twoNewestActiveAdmins = $this->User->scopedFind('activeAdmins', $config);
You can also get a list of available scoped finds:
$scopedFinds = $this->User->scopedFinds();
Scoped finds:
-
require a
name
string -
optionally use a
find
array
The find
arrays:
-
optionally use a
type
string (defaults toall
) -
optionally use an
options
array -
optionally use
virtualFields
The options
arrays:
- can use the behaviors’
scope
property - support all other find options (including contain, order, group, limit, …)
Tip: See the test cases for more complex examples.
Testing
You should test your scopes, even if it’s just something like this:
public function testScopes() {
$scopes = $this->User->scopes;
// Each on its own
foreach ($scopes as $scope) {
$this->User->find('first', array('scope' => $scope));
}
// All together
$this->User->find('first', array('scope' => $scopes));
}
In case there was invalid SQL, missing fields, wrong contain statements, it would be noticeable right away.
If you use scopedFinds, don’t forget to also unit test them (regarding valid SQL).
This can easily be forgotten now as you don’t have the find wrapper methods anymore.
In case you are lazy, add this test case to any model test that uses custom scopedFinds:
public function testScopedFinds() {
$scopedFinds = $this->User->scopedFinds();
foreach ($scopedFinds as $key) {
$this->User->scopedFind($key);
}
}
This will at least execute each find and throw an error if the SQL is invalid.
It is advisable to have a more thorough test case for each find key, though, that includes the assert of the return value.
Outlook
I bet when this behavior is thoroughly used, there will be quite a few adjustments necessary. But all in all this already seems to cover most of the use cases.
With Cake3 and stackable custom finders much of the functionality we need here will be part of core functionality. Which will be awesome.
Never-the-less, until then this can be a solid solution to keep the scopes/conditions DRY.