Skip to content

Commit

Permalink
Instead of paths, use nested validator objects.
Browse files Browse the repository at this point in the history
@lorenzo made a great suggestion on how to improve nested validators.
Instead of using paths, we can nest or mount validators inside of each
other. With a few pre-checks this works really well.
  • Loading branch information
markstory committed May 12, 2015
1 parent a86b0c7 commit 43e869b
Show file tree
Hide file tree
Showing 2 changed files with 185 additions and 30 deletions.
85 changes: 75 additions & 10 deletions src/Validation/Validator.php
Expand Up @@ -17,7 +17,6 @@
use ArrayAccess;
use Cake\Validation\RulesProvider;
use Cake\Validation\ValidationSet;
use Cake\Utility\Hash;
use Countable;
use IteratorAggregate;

Expand All @@ -31,6 +30,12 @@
*/
class Validator implements ArrayAccess, IteratorAggregate, Countable
{
/**
* Used to flag nested rules created with addNested() and addNestedMany()
*
* @var string
*/
const NESTED = '_nested';

/**
* Holds the ValidationSet objects array
Expand Down Expand Up @@ -98,11 +103,9 @@ public function errors(array $data, $newRecord = true)
$requiredMessage = __d('cake', 'This field is required');
$emptyMessage = __d('cake', 'This field cannot be left empty');
}
$flat = Hash::flatten($data);

foreach ($this->_fields as $name => $field) {
$isPath = strpos($name, '.') !== false;
$keyPresent = array_key_exists($name, $isPath ? $flat : $data);
$keyPresent = array_key_exists($name, $data);

if (!$keyPresent && !$this->_checkPresence($field, $newRecord)) {
$errors[$name]['_required'] = isset($this->_presenceMessages[$name])
Expand All @@ -113,12 +116,11 @@ public function errors(array $data, $newRecord = true)
if (!$keyPresent) {
continue;
}
$value = $isPath ? $flat[$name] : $data[$name];

$providers = $this->_providers;
$context = compact('data', 'newRecord', 'field', 'providers');
$canBeEmpty = $this->_canBeEmpty($field, $context);
$isEmpty = $this->_fieldIsEmpty($value);
$isEmpty = $this->_fieldIsEmpty($data[$name]);

if (!$canBeEmpty && $isEmpty) {
$errors[$name]['_empty'] = isset($this->_allowEmptyMessages[$name])
Expand All @@ -131,7 +133,7 @@ public function errors(array $data, $newRecord = true)
continue;
}

$result = $this->_processRules($name, $value, $field, $data, $newRecord);
$result = $this->_processRules($name, $field, $data, $newRecord);
if ($result) {
$errors[$name] = $result;
}
Expand Down Expand Up @@ -308,6 +310,67 @@ public function add($field, $name, $rule = [])
return $this;
}

/**
* Adds a nested validator.
*
* Nesting validators allows you to define validators for array
* types. For example, nested validators are ideal when you want to validate a
* sub-document, or complex array type.
*
* This method assumes that the sub-document has a 1:1 relationship with the parent.
*
* @param string $field The root field for the nested validator.
* @param \Cake\Validation\Validator $validator The nested validator.
* @return $this
*/
public function addNested($field, Validator $validator)
{
$field = $this->field($field);
$field->add(static::NESTED, ['rule' => function ($value, $context) use ($validator) {
if (!is_array($value)) {
return false;
}
$errors = $validator->errors($value);
return empty($errors) ? true : $errors;
}]);
return $this;
}

/**
* Adds a nested validator.
*
* Nesting validators allows you to define validators for array
* types. For example, nested validators are ideal when you want to validate many
* similar sub-documents or complex array types.
*
* This method assumes that the sub-document has a 1:N relationship with the parent.
*
* @param string $field The root field for the nested validator.
* @param \Cake\Validation\Validator $validator The nested validator.
* @return $this
*/
public function addNestedMany($field, Validator $validator)
{
$field = $this->field($field);
$field->add(static::NESTED, ['rule' => function ($value, $context) use ($validator) {
if (!is_array($value)) {
return false;
}
$errors = [];
foreach ($value as $i => $row) {
if (!is_array($row)) {
return false;
}
$check = $validator->errors($row);
if (!empty($check)) {
$errors[$i] = $check;
}
}
return empty($errors) ? true : $errors;
}]);
return $this;
}

/**
* Removes a rule from the set by its name
*
Expand Down Expand Up @@ -550,12 +613,11 @@ protected function _fieldIsEmpty($data)
*
* @param string $field The name of the field that is being processed
* @param ValidationSet $rules the list of rules for a field
* @param mixed $value The field value.
* @param array $data the full data passed to the validator
* @param bool $newRecord whether is it a new record or an existing one
* @return array
*/
protected function _processRules($field, $value, ValidationSet $rules, $data, $newRecord)
protected function _processRules($field, ValidationSet $rules, $data, $newRecord)
{
$errors = [];
// Loading default provider in case there is none
Expand All @@ -567,12 +629,15 @@ protected function _processRules($field, $value, ValidationSet $rules, $data, $n
}

foreach ($rules as $name => $rule) {
$result = $rule->process($value, $this->_providers, compact('newRecord', 'data', 'field'));
$result = $rule->process($data[$field], $this->_providers, compact('newRecord', 'data', 'field'));
if ($result === true) {
continue;
}

$errors[$name] = $message;
if (is_array($result) && $name === static::NESTED) {
$errors = $result;
}
if (is_string($result)) {
$errors[$name] = $result;
}
Expand Down
130 changes: 110 additions & 20 deletions tests/TestCase/Validation/ValidatorTest.php
Expand Up @@ -47,28 +47,33 @@ public function testAddingRulesToField()
}

/**
* Testing you can add nested field rules
* Testing addNested field rules
*
* @return void
*/
public function testAddingNestedRulesToField()
public function testAddNestedSingle()
{
$validator = new Validator;
$validator->add('user.username', 'not-blank', ['rule' => 'notBlank']);
$this->assertCount(0, $validator->field('user'));

$set = $validator->field('user.username');
$this->assertInstanceOf('Cake\Validation\ValidationSet', $set);
$this->assertCount(1, $set);
$validator = new Validator();
$inner = new Validator();
$inner->add('username', 'not-blank', ['rule' => 'notBlank']);
$this->assertSame($validator, $validator->addNested('user', $inner));

$validator->add('user.username', 'letters', ['rule' => 'alphanumeric']);
$this->assertCount(2, $set);
$this->assertCount(1, $validator->field('user'));
}

$validator->remove('user.username', 'letters');
$this->assertCount(1, $set);
/**
* Testing addNestedMany field rules
*
* @return void
*/
public function testAddNestedMany()
{
$validator = new Validator();
$inner = new Validator();
$inner->add('comment', 'not-blank', ['rule' => 'notBlank']);
$this->assertSame($validator, $validator->addNestedMany('comments', $inner));

$validator->requirePresence('user.twitter');
$this->assertTrue($validator->field('user.twitter')->isPresenceRequired());
$this->assertCount(1, $validator->field('comments'));
}

/**
Expand Down Expand Up @@ -195,9 +200,15 @@ public function testErrorsWithPresenceRequired()
*/
public function testErrorsWithNestedFields()
{
$validator = new Validator;
$validator->add('user.username', 'letter', ['rule' => 'alphanumeric']);
$validator->add('comments.0.comment', 'letter', ['rule' => 'alphanumeric']);
$validator = new Validator();
$user = new Validator();
$user->add('username', 'letter', ['rule' => 'alphanumeric']);

$comments = new Validator();
$comments->add('comment', 'letter', ['rule' => 'alphanumeric']);

$validator->addNested('user', $user);
$validator->addNestedMany('comments', $comments);

$data = [
'user' => [
Expand All @@ -209,8 +220,87 @@ public function testErrorsWithNestedFields()
];
$errors = $validator->errors($data);
$expected = [
'user.username' => ['letter' => 'The provided value is invalid'],
'comments.0.comment' => ['letter' => 'The provided value is invalid']
'user' => [
'username' => ['letter' => 'The provided value is invalid']
],
'comments' => [
0 => ['comment' => ['letter' => 'The provided value is invalid']]
]
];
$this->assertEquals($expected, $errors);
}

/**
* Test nested fields with many, but invalid data.
*
* @return void
*/
public function testErrorsWithNestedSingleInvalidType()
{
$validator = new Validator();

$user = new Validator();
$user->add('user', 'letter', ['rule' => 'alphanumeric']);
$validator->addNested('user', $user);

$data = [
'user' => 'a string',
];
$errors = $validator->errors($data);
$expected = [
'user' => ['_nested' => 'The provided value is invalid'],
];
$this->assertEquals($expected, $errors);
}

/**
* Test nested fields with many, but invalid data.
*
* @return void
*/
public function testErrorsWithNestedManyInvalidType()
{
$validator = new Validator();

$comments = new Validator();
$comments->add('comment', 'letter', ['rule' => 'alphanumeric']);
$validator->addNestedMany('comments', $comments);

$data = [
'comments' => 'a string',
];
$errors = $validator->errors($data);
$expected = [
'comments' => ['_nested' => 'The provided value is invalid'],
];
$this->assertEquals($expected, $errors);
}

/**
* Test nested fields with many, but invalid data.
*
* @return void
*/
public function testErrorsWithNestedManySomeInvalid()
{
$validator = new Validator();

$comments = new Validator();
$comments->add('comment', 'letter', ['rule' => 'alphanumeric']);
$validator->addNestedMany('comments', $comments);

$data = [
'comments' => [
'a string',
['comment' => 'letters'],
['comment' => 'more invalid']
]
];
$errors = $validator->errors($data);
$expected = [
'comments' => [
'_nested' => 'The provided value is invalid',
],
];
$this->assertEquals($expected, $errors);
}
Expand Down

0 comments on commit 43e869b

Please sign in to comment.