diff --git a/system/Validation/DotArrayFilter.php b/system/Validation/DotArrayFilter.php new file mode 100644 index 000000000000..cead4f6bb414 --- /dev/null +++ b/system/Validation/DotArrayFilter.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Validation; + +final class DotArrayFilter +{ + /** + * Creates a new array with only the elements specified in dot array syntax. + * + * This code comes from the dot_array_search() function. + * + * @param array $indexes The dot array syntax pattern to use for filtering. + * @param array $array The array to filter. + * + * @return array The filtered array. + */ + public static function run(array $indexes, array $array): array + { + $result = []; + + foreach ($indexes as $index) { + // See https://regex101.com/r/44Ipql/1 + $segments = preg_split( + '/(? str_replace('\.', '.', $key), + $segments + ); + + $result = array_merge_recursive($result, self::filter($segments, $array)); + } + + return $result; + } + + /** + * Used by `run()` to recursively filter the array with wildcards. + * + * @param array $indexes The dot array syntax pattern to use for filtering. + * @param array $array The array to filter. + * + * @return array The filtered array. + */ + private static function filter(array $indexes, array $array): array + { + // If index is empty, returns empty array. + if ($indexes === []) { + return []; + } + + // Grab the current index. + $currentIndex = array_shift($indexes); + + if (! isset($array[$currentIndex]) && $currentIndex !== '*') { + return []; + } + + // Handle Wildcard (*) + if ($currentIndex === '*') { + $answer = []; + + foreach ($array as $key => $value) { + if (! is_array($value)) { + continue; + } + + $result = self::filter($indexes, $value); + + if ($result !== []) { + $answer[$key] = $result; + } + } + + return $answer; + } + + // If this is the last index, make sure to return it now, + // and not try to recurse through things. + if (empty($indexes)) { + return [$currentIndex => $array[$currentIndex]]; + } + + // Do we need to recursively filter this value? + if (is_array($array[$currentIndex]) && $array[$currentIndex] !== []) { + $result = self::filter($indexes, $array[$currentIndex]); + + if ($result !== []) { + return [$currentIndex => $result]; + } + } + + // Otherwise, not found. + return []; + } +} diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index c22f38ae2413..b4131af95305 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -55,6 +55,13 @@ class Validation implements ValidationInterface */ protected $data = []; + /** + * The data that was actually validated. + * + * @var array + */ + protected $validated = []; + /** * Any generated errors during validation. * 'key' is the alias, 'value' is the message. @@ -109,7 +116,12 @@ public function __construct($config, RendererInterface $view) */ public function run(?array $data = null, ?string $group = null, ?string $dbGroup = null): bool { - $data ??= $this->data; + if ($data === null) { + $data = $this->data; + } else { + // Store data to validate. + $this->data = $data; + } // i.e. is_unique $data['DBGroup'] = $dbGroup; @@ -171,7 +183,17 @@ public function run(?array $data = null, ?string $group = null, ?string $dbGroup } } - return $this->getErrors() === []; + if ($this->getErrors() === []) { + // Store data that was actually validated. + $this->validated = DotArrayFilter::run( + array_keys($this->rules), + $this->data + ); + + return true; + } + + return false; } /** @@ -188,6 +210,14 @@ public function check($value, string $rule, array $errors = []): bool return $this->setRule('check', null, $rule, $errors)->run(['check' => $value]); } + /** + * Returns actually validated data. + */ + public function getValidated(): array + { + return $this->validated; + } + /** * Runs all of $rules against $field, until one fails, or * all of them have been processed. If one fails, it adds @@ -827,6 +857,7 @@ protected function splitRules(string $rules): array public function reset(): ValidationInterface { $this->data = []; + $this->validated = []; $this->rules = []; $this->errors = []; $this->customErrors = []; diff --git a/tests/system/Validation/DotArrayFilterTest.php b/tests/system/Validation/DotArrayFilterTest.php new file mode 100644 index 000000000000..6d3b380f517a --- /dev/null +++ b/tests/system/Validation/DotArrayFilterTest.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Validation; + +use CodeIgniter\Test\CIUnitTestCase; + +/** + * @internal + * + * @group Others + */ +final class DotArrayFilterTest extends CIUnitTestCase +{ + public function testRunReturnEmptyArray() + { + $data = []; + + $result = DotArrayFilter::run(['foo.bar'], $data); + + $this->assertSame([], $result); + } + + public function testRunReturnEmptyArrayMissingValue() + { + $data = [ + 'foo' => [ + 'bar' => 23, + ], + ]; + + $result = DotArrayFilter::run(['foo.baz'], $data); + + $this->assertSame([], $result); + } + + public function testRunReturnEmptyArrayEmptyIndex() + { + $data = [ + 'foo' => [ + 'bar' => 23, + ], + ]; + + $result = DotArrayFilter::run([''], $data); + + $this->assertSame([], $result); + } + + public function testRunEarlyIndex() + { + $data = [ + 'foo' => [ + 'bar' => 23, + ], + ]; + + $result = DotArrayFilter::run(['foo'], $data); + + $this->assertSame($data, $result); + } + + public function testRunWildcard() + { + $data = [ + 'foo' => [ + 'bar' => [ + 'baz' => 23, + ], + ], + ]; + + $result = DotArrayFilter::run(['foo.*.baz'], $data); + + $this->assertSame($data, $result); + } + + public function testRunWildcardWithMultipleChoices() + { + $data = [ + 'foo' => [ + 'buzz' => [ + 'fizz' => 11, + ], + 'bar' => [ + 'baz' => 23, + ], + ], + ]; + + $result = DotArrayFilter::run(['foo.*.fizz', 'foo.*.baz'], $data); + + $this->assertSame($data, $result); + } + + public function testRunNestedNotFound() + { + $data = [ + 'foo' => [ + 'buzz' => [ + 'fizz' => 11, + ], + 'bar' => [ + 'baz' => 23, + ], + ], + ]; + + $result = DotArrayFilter::run(['foo.*.notthere'], $data); + + $this->assertSame([], $result); + } + + public function testRunIgnoresLastWildcard() + { + $data = [ + 'foo' => [ + 'bar' => [ + 'baz' => 23, + ], + ], + ]; + + $result = DotArrayFilter::run(['foo.bar.*'], $data); + + $this->assertSame($data, $result); + } + + public function testRunNestedArray() + { + $array = [ + 'user' => [ + 'name' => 'John', + 'age' => 30, + 'email' => 'john@example.com', + 'preferences' => [ + 'theme' => 'dark', + 'language' => 'en', + 'notifications' => [ + 'email' => true, + 'push' => false, + ], + ], + ], + 'product' => [ + 'name' => 'Acme Product', + 'description' => 'This is a great product!', + 'price' => 19.99, + ], + ]; + + $result = DotArrayFilter::run([ + 'user.name', + 'user.preferences.language', + 'user.preferences.notifications.email', + 'product.name', + ], $array); + + $expected = [ + 'user' => [ + 'name' => 'John', + 'preferences' => [ + 'language' => 'en', + 'notifications' => [ + 'email' => true, + ], + ], + ], + 'product' => [ + 'name' => 'Acme Product', + ], + ]; + $this->assertSame($expected, $result); + } +} diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index 2c6d52337fe9..1787377348e1 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -234,13 +234,16 @@ public function testRunReturnsFalseWithNothingToDo() { $this->validation->setRules([]); $this->assertFalse($this->validation->run([])); + $this->assertSame([], $this->validation->getValidated()); } public function testRunDoesTheBasics(): void { $data = ['foo' => 'notanumber']; $this->validation->setRules(['foo' => 'is_numeric']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame([], $this->validation->getValidated()); } public function testClosureRule(): void @@ -259,13 +262,14 @@ public function testClosureRule(): void ); $data = ['foo' => 'xyz']; - $return = $this->validation->run($data); + $result = $this->validation->run($data); - $this->assertFalse($return); + $this->assertFalse($result); $this->assertSame( ['foo' => 'The value is not "abc"'], $this->validation->getErrors() ); + $this->assertSame([], $this->validation->getValidated()); } public function testClosureRuleWithParamError(): void @@ -286,13 +290,14 @@ static function ($value, $data, &$error, $field) { ]); $data = ['foo' => 'xyz']; - $return = $this->validation->run($data); + $result = $this->validation->run($data); - $this->assertFalse($return); + $this->assertFalse($result); $this->assertSame( ['foo' => 'The foo value is not "abc"'], $this->validation->getErrors() ); + $this->assertSame([], $this->validation->getValidated()); } public function testClosureRuleWithLabel(): void @@ -309,9 +314,9 @@ public function testClosureRuleWithLabel(): void ]); $data = ['secret' => 'xyz']; - $return = $this->validation->run($data); + $result = $this->validation->run($data); - $this->assertFalse($return); + $this->assertFalse($result); $this->assertSame( ['secret' => 'The シークレット is invalid'], $this->validation->getErrors() @@ -437,8 +442,9 @@ public function testRunWithCustomErrors(): void ], ]; $this->validation->setRules(['foo' => 'is_numeric', 'bar' => 'is_numeric'], $messages); - $this->validation->run($data); + $result = $this->validation->run($data); + $this->assertFalse($result); $this->assertSame('Nope. Not a number.', $this->validation->getError('foo')); $this->assertSame('No. Not a number.', $this->validation->getError('bar')); } @@ -464,8 +470,9 @@ public function testSetRuleWithCustomErrors(): void ['bar' => 'is_numeric'], ['is_numeric' => 'Nope. Not a number.'] ); - $this->validation->run($data); + $result = $this->validation->run($data); + $this->assertFalse($result); $this->assertSame('Nope. Not a number.', $this->validation->getError('foo')); $this->assertSame('Nope. Not a number.', $this->validation->getError('bar')); } @@ -493,7 +500,9 @@ public function testGetErrors(): void { $data = ['foo' => 'notanumber']; $this->validation->setRules(['foo' => 'is_numeric']); - $this->validation->run($data); + $result = $this->validation->run($data); + + $this->assertFalse($result); $this->assertSame(['foo' => 'Validation.is_numeric'], $this->validation->getErrors()); } @@ -501,7 +510,9 @@ public function testGetErrorsWhenNone(): void { $data = ['foo' => 123]; $this->validation->setRules(['foo' => 'is_numeric']); - $this->validation->run($data); + $result = $this->validation->run($data); + + $this->assertTrue($result); $this->assertSame([], $this->validation->getErrors()); } @@ -515,7 +526,7 @@ public function testSetErrors(): void public function testRulesReturnErrors(): void { $this->validation->setRules(['foo' => 'customError']); - $this->validation->run(['foo' => 'bar']); + $this->assertFalse($this->validation->run(['foo' => 'bar'])); $this->assertSame(['foo' => 'My lovely error'], $this->validation->getErrors()); } @@ -564,6 +575,7 @@ public function testSetRuleGroupWithCustomErrorMessage(): void $this->validation->reset(); $this->validation->setRuleGroup('login'); $this->validation->run(['username' => 'codeigniter']); + $this->assertSame([ 'password' => 'custom password required error msg.', ], $this->validation->getErrors()); @@ -713,48 +725,46 @@ public function testInvalidRule(): void public function testRawInput(): void { - $rawstring = 'username=admin001&role=administrator&usepass=0'; - - $data = [ - 'username' => 'admin001', - 'role' => 'administrator', - 'usepass' => 0, - ]; - + $rawstring = 'username=admin001&role=administrator&usepass=0'; $config = new App(); $config->baseURL = 'http://example.com/'; + $request = new IncomingRequest($config, new URI(), $rawstring, new UserAgent()); - $request = new IncomingRequest($config, new URI(), $rawstring, new UserAgent()); - $this->validation->withRequest($request->withMethod('patch'))->run($data); + $rules = [ + 'role' => 'required|min_length[5]', + ]; + $result = $this->validation->withRequest($request->withMethod('patch'))->setRules($rules)->run(); + + $this->assertTrue($result); $this->assertSame([], $this->validation->getErrors()); + $this->assertSame(['role' => 'administrator'], $this->validation->getValidated()); } public function testJsonInput(): void { + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $data = [ 'username' => 'admin001', 'role' => 'administrator', 'usepass' => 0, ]; - $json = json_encode($data); - - $_SERVER['CONTENT_TYPE'] = 'application/json'; - + $json = json_encode($data); $config = new App(); $config->baseURL = 'http://example.com/'; - - $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); + $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); $rules = [ 'role' => 'required|min_length[5]', ]; - $validated = $this->validation + $result = $this->validation ->withRequest($request->withMethod('patch')) ->setRules($rules) ->run(); - $this->assertTrue($validated); + $this->assertTrue($result); $this->assertSame([], $this->validation->getErrors()); + $this->assertSame(['role' => 'administrator'], $this->validation->getValidated()); unset($_SERVER['CONTENT_TYPE']); } @@ -784,12 +794,12 @@ public function testJsonInputObjectArray(): void $rules = [ 'p' => 'required|array_count[2]', ]; - $validated = $this->validation + $result = $this->validation ->withRequest($request->withMethod('patch')) ->setRules($rules) ->run(); - $this->assertFalse($validated); + $this->assertFalse($result); $this->assertSame(['p' => 'Validation.array_count'], $this->validation->getErrors()); unset($_SERVER['CONTENT_TYPE']); @@ -917,7 +927,10 @@ public function testTagReplacement(): void 'min_length' => 'Supplied value ({value}) for {field} must have at least {param} characters.', ]] ); - $this->validation->run($data); + $result = $this->validation->run($data); + + $this->assertFalse($result); + $errors = $this->validation->getErrors(); if (! isset($errors['Username'])) { @@ -934,8 +947,10 @@ public function testRulesForObjectField(): void 'configuration' => 'required|check_object_rule', ]); - $data = (object) ['configuration' => (object) ['first' => 1, 'second' => 2]]; - $this->validation->run((array) $data); + $data = (object) ['configuration' => (object) ['first' => 1, 'second' => 2]]; + $result = $this->validation->run((array) $data); + + $this->assertTrue($result); $this->assertSame([], $this->validation->getErrors()); $this->validation->reset(); @@ -943,9 +958,10 @@ public function testRulesForObjectField(): void 'configuration' => 'required|check_object_rule', ]); - $data = (object) ['configuration' => (object) ['first1' => 1, 'second' => 2]]; - $this->validation->run((array) $data); + $data = (object) ['configuration' => (object) ['first1' => 1, 'second' => 2]]; + $result = $this->validation->run((array) $data); + $this->assertFalse($result); $this->assertSame([ 'configuration' => 'Validation.check_object_rule', ], $this->validation->getErrors()); @@ -1168,7 +1184,6 @@ public function testTranslatedLabelWithCustomErrorMessage(): void public function testTranslatedLabelTagReplacement(): void { $data = ['Username' => 'Pizza']; - $this->validation->setRules( ['Username' => [ 'label' => 'Foo.bar', @@ -1178,8 +1193,10 @@ public function testTranslatedLabelTagReplacement(): void 'min_length' => 'Foo.bar.min_length2', ]] ); + $result = $this->validation->run($data); + + $this->assertFalse($result); - $this->validation->run($data); $errors = $this->validation->getErrors(); if (! isset($errors['Username'])) { @@ -1507,8 +1524,9 @@ public function testNestedArrayThrowsException(): void 'debit_amount' => '1500', 'beneficiaries_accounts' => [], ]; - $this->validation->run($data); + $result = $this->validation->run($data); + $this->assertFalse($result); $this->assertSame([ 'beneficiaries_accounts.*.account_number' => 'The BENEFICIARY ACCOUNT NUMBER field must be exactly 5 characters in length.', 'beneficiaries_accounts.*.credit_amount' => 'The CREDIT AMOUNT field is required.', @@ -1538,8 +1556,9 @@ public function testNestedArrayThrowsException(): void ], ], ]; - $this->validation->run($data); + $result = $this->validation->run($data); + $this->assertFalse($result); $this->assertSame([ 'beneficiaries_accounts.account_3.account_number' => 'The BENEFICIARY ACCOUNT NUMBER field must be exactly 5 characters in length.', 'beneficiaries_accounts.account_2.credit_amount' => 'The CREDIT AMOUNT field is required.', diff --git a/user_guide_src/source/changelogs/v4.4.0.rst b/user_guide_src/source/changelogs/v4.4.0.rst index 3c4fe991d3c6..dd2fc83ef8b7 100644 --- a/user_guide_src/source/changelogs/v4.4.0.rst +++ b/user_guide_src/source/changelogs/v4.4.0.rst @@ -78,6 +78,9 @@ Model Libraries ========= +- **Validation:** Added ``Validation::getValidated()`` method that gets + the actual validated data. See :ref:`validation-getting-validated-data` for details. + Helpers and Functions ===================== diff --git a/user_guide_src/source/libraries/validation.rst b/user_guide_src/source/libraries/validation.rst index 823a8b899bb3..0eebba50b168 100644 --- a/user_guide_src/source/libraries/validation.rst +++ b/user_guide_src/source/libraries/validation.rst @@ -345,6 +345,19 @@ Validate one value against a rule: .. literalinclude:: validation/012.php +.. _validation-getting-validated-data: + +Getting Validated Data +====================== + +.. versionadded:: 4.4.0 + +The actual validated data can be retrieved with the ``getValidated()`` method. +This method returns an array of only those elements that have been validated by +the validation rules. + +.. literalinclude:: validation/043.php + Saving Sets of Validation Rules to the Config File ================================================== diff --git a/user_guide_src/source/libraries/validation/043.php b/user_guide_src/source/libraries/validation/043.php new file mode 100644 index 000000000000..7f3a0e0e0902 --- /dev/null +++ b/user_guide_src/source/libraries/validation/043.php @@ -0,0 +1,21 @@ +setRules([ + 'username' => 'required', + 'password' => 'required|min_length[10]', +]); + +$data = [ + 'username' => 'john', + 'password' => 'BPi-$Swu7U5lm$dX', + 'csrf_token' => '8b9218a55906f9dcc1dc263dce7f005a', +]; + +if ($validation->run($data)) { + $validatedData = $validation->getValidated(); + // $validatedData = [ + // 'username' => 'john', + // 'password' => 'BPi-$Swu7U5lm$dX', + // ]; +}