From f636aa46550c0c28d0cfc6c66de6620f0c2201f0 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 6 Aug 2026 03:43:18 +0600 Subject: [PATCH 1/7] feat: validate SQL identifiers --- src/Rules/SqlIdentifierRule.php | 21 +++++++++++++ tests/Rules/SqlIdentifierTest.php | 49 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/Rules/SqlIdentifierRule.php create mode 100644 tests/Rules/SqlIdentifierTest.php diff --git a/src/Rules/SqlIdentifierRule.php b/src/Rules/SqlIdentifierRule.php new file mode 100644 index 0000000..7a1d0d8 --- /dev/null +++ b/src/Rules/SqlIdentifierRule.php @@ -0,0 +1,21 @@ +message; + } +} diff --git a/tests/Rules/SqlIdentifierTest.php b/tests/Rules/SqlIdentifierTest.php new file mode 100644 index 0000000..1d5f949 --- /dev/null +++ b/tests/Rules/SqlIdentifierTest.php @@ -0,0 +1,49 @@ +validate($value))->toBeTrue(); +})->with('valid SQL identifiers'); + +test('rejects values that are not a single SQL identifier segment', function ($value) { + $rule = new SqlIdentifierRule(); + + expect($rule->validate($value))->toBeFalse(); +})->with('invalid SQL identifiers'); + +test('resolves sql_identifier and returns its validation message', function () { + $validator = new Validator(); + + $validation = $validator->make( + ['column' => 'users.id'], + ['column' => ['sql_identifier']] + ); + + expect($validation->errors())->toBe([ + 'column' => ['The column must be a valid SQL identifier'], + ]); +}); From b5686be36f55c19528fefd40e4bec160e8a76e0f Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 6 Aug 2026 08:02:05 +0600 Subject: [PATCH 2/7] feat: validate query sort directions --- src/Rules/SortDirectionRule.php | 21 ++++++++ tests/Rules/SortDirectionTest.php | 32 ++++++++++++ tests/SqlStructureValidationTest.php | 75 ++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/Rules/SortDirectionRule.php create mode 100644 tests/Rules/SortDirectionTest.php create mode 100644 tests/SqlStructureValidationTest.php diff --git a/src/Rules/SortDirectionRule.php b/src/Rules/SortDirectionRule.php new file mode 100644 index 0000000..a0fc06d --- /dev/null +++ b/src/Rules/SortDirectionRule.php @@ -0,0 +1,21 @@ +message; + } +} diff --git a/tests/Rules/SortDirectionTest.php b/tests/Rules/SortDirectionTest.php new file mode 100644 index 0000000..0c90771 --- /dev/null +++ b/tests/Rules/SortDirectionTest.php @@ -0,0 +1,32 @@ +validate($value))->toBeTrue(); +})->with('valid sort directions'); + +test('rejects values that are not exact sort directions', function ($value) { + $rule = new SortDirectionRule(); + + expect($rule->validate($value))->toBeFalse(); +})->with('invalid sort directions'); diff --git a/tests/SqlStructureValidationTest.php b/tests/SqlStructureValidationTest.php new file mode 100644 index 0000000..fe394ba --- /dev/null +++ b/tests/SqlStructureValidationTest.php @@ -0,0 +1,75 @@ + [[], [], false], + 'null' => [['value' => null], ['value' => null], false], + 'empty string' => [['value' => ''], ['value' => ''], false], + 'empty array' => [['value' => []], ['value' => []], false], + 'false' => [['value' => false], null, true], +]); + +test('validates safe SQL structure values without changing them', function () { + $validator = (new Validator())->make( + ['sortBy' => 'custom_field_12', 'sortOrder' => 'DESC'], + [ + 'sortBy' => ['required', 'sql_identifier'], + 'sortOrder' => ['required', 'sort_direction'], + ] + ); + + expect($validator->fails())->toBeFalse() + ->and($validator->validated())->toBe([ + 'sortBy' => 'custom_field_12', + 'sortOrder' => 'DESC', + ]); +}); + +test('returns the first labeled error for each malicious SQL structure value', function () { + $validator = (new Validator())->make( + [ + 'sortBy' => 'id`,(SELECT sleep(22) )#', + 'sortOrder' => 'desc; DROP', + ], + [ + 'sortBy' => ['required', 'sql_identifier'], + 'sortOrder' => ['required', 'sort_direction'], + ], + [], + [ + 'sortBy' => 'sort column', + 'sortOrder' => 'sort direction', + ] + ); + + expect($validator->fails())->toBeTrue() + ->and($validator->errors())->toBe([ + 'sortBy' => ['The sort column must be a valid SQL identifier'], + 'sortOrder' => ['The sort direction must be asc or desc'], + ]) + ->and($validator->validated())->toBe([ + 'sortBy' => ['The sort column must be a valid SQL identifier'], + 'sortOrder' => ['The sort direction must be asc or desc'], + ]); +}); + +test('nullable skips SQL structure validation only for values considered empty', function ($rule, $data, $expectedValidated, $fails) { + $validator = (new Validator())->make( + $data, + ['value' => ['nullable', $rule]] + ); + + expect($validator->fails())->toBe($fails); + + if ($fails) { + expect($validator->errors())->toHaveKey('value'); + + return; + } + + expect($validator->validated())->toBe($expectedValidated); +})->with([ + 'sql identifier' => ['sql_identifier'], + 'sort direction' => ['sort_direction'], +])->with('nullable SQL structure values'); From 427bc56be8d8efe59f7623eb1d46ac0574c757bd Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 6 Aug 2026 08:05:27 +0600 Subject: [PATCH 3/7] test: cover validation first-failure behavior --- tests/SqlStructureValidationTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/SqlStructureValidationTest.php b/tests/SqlStructureValidationTest.php index fe394ba..3c45fec 100644 --- a/tests/SqlStructureValidationTest.php +++ b/tests/SqlStructureValidationTest.php @@ -33,8 +33,8 @@ 'sortOrder' => 'desc; DROP', ], [ - 'sortBy' => ['required', 'sql_identifier'], - 'sortOrder' => ['required', 'sort_direction'], + 'sortBy' => ['required', 'sql_identifier', 'uppercase'], + 'sortOrder' => ['required', 'sort_direction', 'uppercase'], ], [], [ From 5a00642515a4383302f227253df1f21b712a3d7d Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 6 Aug 2026 08:09:41 +0600 Subject: [PATCH 4/7] docs: document SQL structure validation --- README.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9de83ea..2b88188 100644 --- a/README.md +++ b/README.md @@ -186,11 +186,35 @@ Checks if the field under validation has exactly the same size as `:size`. Checks if the given value is a string. 23. **`uppercase`**
Checks if the string value consists of all uppercase letters. -24. **`url`**
+24. **`sql_identifier`**
+Validates one unqualified identifier segment. It does not quote SQL. +25. **`sort_direction`**
+Validates `asc` or `desc`. It does not add an `ORDER BY` clause. +26. **`url`**
Checks if the value is a valid URL. Missing any validation rule that you need? Refer to the [Custom Validation Rule](#custom-validation-rule) section to know how you can create and use custom validation rules in your project alongside the library. +### SQL Structure Validation + +Use `sql_identifier` and `sort_direction` to validate request fields that select a +column and sort direction: + +```php +$rules = [ + 'sortBy' => ['required', 'string', 'sql_identifier'], + 'sortOrder' => ['required', 'string', 'sort_direction'], +]; +``` + +sql_identifier validates one unqualified identifier segment. It does not quote SQL. +sort_direction validates asc/desc. It does not add an ORDER BY clause. +sanitize:text and sanitize:key are not SQL-context security controls. +Raw SQL still requires typed identifier handling in the database layer. +nullable skips later rules for values the validator considers empty, including an +empty string and empty array; do not use nullable for security-sensitive sort fields +unless the request layer has already removed/defaulted empty input. + ### Custom Validation Rule Create the class of the validation rule into your project: From 5cee354cdaaa3eb5abb884790933f6a86cead068 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 6 Aug 2026 08:15:58 +0600 Subject: [PATCH 5/7] fix: restore PHP 5.6 helper compatibility --- src/Helpers.php | 6 +++++- tests/HelpersTest.php | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/HelpersTest.php diff --git a/src/Helpers.php b/src/Helpers.php index e5a0d37..03c396d 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -42,7 +42,11 @@ public function setNestedElement(&$data, $keys, $value) $current->$key = new stdClass(); } - $current = &$current[$key] ?? $current->$key; + if (is_array($current)) { + $current = &$current[$key]; + } else { + $current = &$current->$key; + } } $current = $value; diff --git a/tests/HelpersTest.php b/tests/HelpersTest.php new file mode 100644 index 0000000..a0ef4b2 --- /dev/null +++ b/tests/HelpersTest.php @@ -0,0 +1,36 @@ +setNestedElement($data, ['profile', 'name'], 'Ada'); + + expect($result)->toBe('Ada') + ->and($data)->toBe([ + 'profile' => [ + 'name' => 'Ada', + ], + ]); +}); + +test('sets a nested object property by reference', function () { + $helpers = new HelpersTestHarness(); + $data = new stdClass(); + + $result = $helpers->setNestedElement($data, ['profile', 'name'], 'Ada'); + + expect($result)->toBe('Ada') + ->and($data)->toEqual((object) [ + 'profile' => (object) [ + 'name' => 'Ada', + ], + ]); +}); From 15187b9fb116e4e961042ba23833790494cf12c7 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 6 Aug 2026 08:16:04 +0600 Subject: [PATCH 6/7] ci: add PHP 5.6 parse smoke gate --- .github/workflows/php56-parse-smoke.yml | 42 +++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/php56-parse-smoke.yml diff --git a/.github/workflows/php56-parse-smoke.yml b/.github/workflows/php56-parse-smoke.yml new file mode 100644 index 0000000..2aa6b29 --- /dev/null +++ b/.github/workflows/php56-parse-smoke.yml @@ -0,0 +1,42 @@ +name: PHP 5.6 Parse and Smoke + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + php56-parse-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP 5.6 + uses: shivammathur/setup-php@v2 + with: + php-version: '5.6' + coverage: none + tools: none + + - name: Parse source files + run: find src -type f -name '*.php' -print0 | xargs -0 -n1 php -l + + - name: Smoke SQL structure rules + run: | + php -r ' + require "src/Rule.php"; + require "src/Rules/SqlIdentifierRule.php"; + require "src/Rules/SortDirectionRule.php"; + + $identifier = new \BitApps\WPValidator\Rules\SqlIdentifierRule(); + $direction = new \BitApps\WPValidator\Rules\SortDirectionRule(); + + if (!$identifier->validate("sort_column") + || $identifier->validate("users.sort_column") + || !$direction->validate("DESC") + || $direction->validate("desc ") + || $direction->validate(0)) { + fwrite(STDERR, "PHP 5.6 SQL structure rule smoke check failed\n"); + exit(1); + } + ' From 61e6b4ed682afdfe38cca72008f5aca4e97fc609 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Thu, 6 Aug 2026 08:32:41 +0600 Subject: [PATCH 7/7] fix: preserve nullable helper behavior --- README.md | 14 ++++++++------ src/Helpers.php | 4 ++++ tests/HelpersTest.php | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2b88188..cb2d3a9 100644 --- a/README.md +++ b/README.md @@ -167,14 +167,15 @@ Checks if the field under validation has a minimum value of `:min`. - For numeric data, the value corresponds to a given integer value. - For an array, the value corresponds to the count of the array. 17. **`nullable`**
-Makes the field under validation as optional (allows to be null), but respects other validation rules if specified and value is not null. +Skips later validation rules when the field is omitted, `null`, an empty string, or an empty array. A value of `false` continues through later validation rules. 18. **`numeric`**
Checks if the field under validation is a valid real number. 19. **`required`**
Checks if the field under validation is present and not empty. A field is "empty" if it meets one of the following criteria: - - The value is `NULL` or `FALSE`. + - The field is omitted or the value is `NULL`. - The value is an empty string. - - The value is an empty array or empty countable object. + - The value is an empty array. + - A value of `FALSE` is not empty and continues through validation. 20. **`same:field`**
Checks if the field under validation is equal to the specified `:other` attribute. 21. **`size:value`**
@@ -211,9 +212,10 @@ sql_identifier validates one unqualified identifier segment. It does not quote S sort_direction validates asc/desc. It does not add an ORDER BY clause. sanitize:text and sanitize:key are not SQL-context security controls. Raw SQL still requires typed identifier handling in the database layer. -nullable skips later rules for values the validator considers empty, including an -empty string and empty array; do not use nullable for security-sensitive sort fields -unless the request layer has already removed/defaulted empty input. +nullable skips later rules for an omitted value, null, an empty string, or an empty +array. A false value continues through later validation; do not use nullable for +security-sensitive sort fields unless the request layer has already removed/defaulted +empty input. ### Custom Validation Rule diff --git a/src/Helpers.php b/src/Helpers.php index 03c396d..bfe8b71 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -36,6 +36,10 @@ public function setNestedElement(&$data, $keys, $value) $current = &$data; foreach ($keys as $key) { + if (is_null($current)) { + $current = []; + } + if (is_array($current) && !isset($current[$key])) { $current[$key] = []; } elseif (is_object($current) && !isset($current->$key)) { diff --git a/tests/HelpersTest.php b/tests/HelpersTest.php index a0ef4b2..ceaabf9 100644 --- a/tests/HelpersTest.php +++ b/tests/HelpersTest.php @@ -21,6 +21,20 @@ class HelpersTestHarness ]); }); +test('promotes a null root to a nested array element', function () { + $helpers = new HelpersTestHarness(); + $data = null; + + $result = $helpers->setNestedElement($data, ['a', 'b'], 'x'); + + expect($result)->toBe('x') + ->and($data)->toBe([ + 'a' => [ + 'b' => 'x', + ], + ]); +}); + test('sets a nested object property by reference', function () { $helpers = new HelpersTestHarness(); $data = new stdClass();