diff --git a/.github/workflows/php72-parse-smoke.yml b/.github/workflows/php72-parse-smoke.yml
new file mode 100644
index 0000000..25fbdcd
--- /dev/null
+++ b/.github/workflows/php72-parse-smoke.yml
@@ -0,0 +1,42 @@
+name: PHP 7.2 Parse and Smoke
+
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ php72-parse-smoke:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up PHP 7.2
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '7.2'
+ 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 7.2 SQL structure rule smoke check failed\n");
+ exit(1);
+ }
+ '
diff --git a/README.md b/README.md
index 33e1612..eaa59cc 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. **`present`**
The field must be present in the input data for validation.
@@ -189,11 +190,36 @@ Checks if the field under validation has exactly the same size as `:size`.
Checks if the given value is a string.
24. **`uppercase`**
Checks if the string value consists of all uppercase letters.
-25. **`url`**
+25. **`sql_identifier`**
+Validates one unqualified identifier segment. It does not quote SQL.
+26. **`sort_direction`**
+Validates `asc` or `desc`. It does not add an `ORDER BY` clause.
+27. **`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 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.
+
### Available sanitization functions
1. **`sanitize_email`**
Strip out all characters that are not allowable in an email address.
diff --git a/src/Helpers.php b/src/Helpers.php
index 1859266..dfa636f 100644
--- a/src/Helpers.php
+++ b/src/Helpers.php
@@ -30,13 +30,21 @@ 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})) {
$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/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/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/HelpersTest.php b/tests/HelpersTest.php
new file mode 100644
index 0000000..ceaabf9
--- /dev/null
+++ b/tests/HelpersTest.php
@@ -0,0 +1,50 @@
+setNestedElement($data, ['profile', 'name'], 'Ada');
+
+ expect($result)->toBe('Ada')
+ ->and($data)->toBe([
+ 'profile' => [
+ 'name' => 'Ada',
+ ],
+ ]);
+});
+
+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();
+
+ $result = $helpers->setNestedElement($data, ['profile', 'name'], 'Ada');
+
+ expect($result)->toBe('Ada')
+ ->and($data)->toEqual((object) [
+ 'profile' => (object) [
+ 'name' => 'Ada',
+ ],
+ ]);
+});
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/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'],
+ ]);
+});
diff --git a/tests/SqlStructureValidationTest.php b/tests/SqlStructureValidationTest.php
new file mode 100644
index 0000000..3c45fec
--- /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', 'uppercase'],
+ 'sortOrder' => ['required', 'sort_direction', 'uppercase'],
+ ],
+ [],
+ [
+ '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');