Custom Validation Rule Classes
I was asked yesterday if I could elaborate on my OwnedByCurrentUser
rule class. I’ll post it here, but also post on my process for developing rules.
Organization
First off, I hate having anonymous functions:
- They are harder to test in isolation of the enclosing scope.
- They make it more difficult to reason about classes because of the implicit extra scope/binding of the callable.
- I think they look silly.
I definitely think they have their place - configuring the CRUD Plugin is one - but normally I try to stay away from them if possible. Instead, I use invokable callable classes.
For rules, I normally place my callable classes in src/Form/Rule
. Here is what our initial OwnedByCurrentUser
rule looks like:
<?php
namespace App\Form\Rule;
class OwnedByCurrentUser
{
/**
* Performs the check
*
* @param mixed $value The data to validate
* @param array $context A key value list of data that could be used as context
* during validation. Recognized keys are:
* - newRecord: (boolean) whether or not the data to be validated belongs to a
* new record
* - data: The full data that was passed to the validation process
* - field: The name of the field that is being processed
* - providers: associative array with objects or class names that will
* be passed as the last argument for the validation method
* @return bool
*/
public function __invoke($value, array $context = null)
{
}
}
?>
Filling it in
When I write a rule, I’ll first write it to handle one very specific case. In this particular application, I had to ensure that a particular Battle
was owned by a participant in the battle before allowing them to perform certain actions. My invoke looked like so:
public function __invoke($value, array $context = null)
{
$table = \Cake\ORM\TableRegistry::get('Battles');
return !!$table->find()->where([
'id' => (int)$value,
'user_id' => $userId,
])->firstOrFail();
}
The above sort of works:
- It actually throws a
Cake\Datasource\Exception\RecordNotFoundException
exception, which is incorrect for my use case, since I don’t want validation rules to throw exceptions - I wasn’t sure where I was passing in the
$userId
. The$context
maybe? - I’m offloading a lot of logic into the database. What if I don’t have compound index on
id/user_id
? That would slow down this part of the app (maybe not a concern). - There was a table where I was thinking of re-using this in the near future that used
creator_id
instead ofuser_id
to denote who owned the record (legacy applications, am I right?). This was hardcoded to the one field, which would mean more copy-pasting. I also couldn’t modify the table that was being checked. Boo.
Once I had a few tests going that brought up the above issues, I knew I had to refactor it.
Fixing issues
I took a step back and realized I wanted to instantiate rules and then invoke them several times. This meant modifying the rule instance state, as well as passing in an initial state. First, lets add a constructor:
protected $_alias;
protected $_userId;
protected $_fieldName;
/**
* Performs the check
*
* @param string $alias Table alias
* @param mixed $userId A string or integer denoting a user's id
* @param string $fieldName A name to use when checking an entity's association
* @return void
*/
public function __construct($alias, $userId, $fieldName = 'user_id')
{
$this->_alias = $alias;
$this->_userId = $userId;
$this->_fieldName = $fieldName;
}
public function setTable($alias)
{
$this->_alias = $alias;
}
public function setUserId($userId)
{
$this->_userId = $userId;
}
public function setFieldName($fieldName)
{
$this->_fieldName = $fieldName;
}
Each field is a protected field - meaning I can extend this easily by subclassing - and all have setters - meaning I can reuse a rule instance if necessary. Next I needed to modify the __invoke()
method to use my customizations:
public function __invoke($value, array $context = null)
{
// handle the case where no userId was
// specified or the user is logged out
$userId = $this->_userId;
if (empty($userId)) {
return false;
}
// use the Table class specified by our configured alias
$table = \Cake\ORM\TableRegistry::get($this->_alias);
// Don't make the database do the heavy-lifting
$entity = $table->find()->where(['id' => (int)$value])->first();
if (empty($entity)) {
return false;
}
// Ensure any customized field matches our userId
return $entity->get($this->_fieldName) == $userId;
}
Wrapping it up
From yesterday’s post, here is how the rule is invoked:
protected function _buildValidator(Validator $validator)
{
// use $this->_user in my validation rules
$userId = $this->_user->get('id');
$validator->add('id', 'custom', [
'rule' => function ($value, $context) use ($userId) {
// reusing an invokable class
$rule = new OwnedByCurrentUser('Battles', $userId);
return $rule($value, $context);
},
'message' => 'This photo isn\'t yours to battle with'
]);
// This should also work
$validator->add('id', 'custom', [
'rule' => new OwnedByCurrentUser('Battles', $userId),
'message' => 'This photo isn\'t yours to battle with'
]);
// As should this (and you can now re-use the rule)
$rule = new OwnedByCurrentUser('Battles', $userId);
$validator->add('id', 'custom', [
'rule' => $rule,
'message' => 'This photo isn\'t yours to battle with'
]);
}
Mopping up
When I first found out I could do this, I was quite delighted by it. Validation rules have always been a pain to test, and this was as good as it got. I now have an easy to understand class that is both easily testable and gives me increased code reuse.