diff --git a/src/Form.php b/src/Form.php index 227a0ae..1134131 100644 --- a/src/Form.php +++ b/src/Form.php @@ -7,9 +7,9 @@ /** * Leaf Form * ---- - * Leaf's form validation library + * Leaf's form validation library with enhanced wildcard and JSON validation support * - * @version 3.0.0 + * @version 3.1.0 * @since 1.0.0 */ class Form @@ -146,7 +146,7 @@ protected function test($rule, $valueToTest, $fieldName = 'item'): bool $rule = $matches[0]; } - if (in_array('optional', $rule) && empty($valueToTest)) { + if (in_array('optional', $rule) && ($valueToTest === null || $valueToTest === '' || $valueToTest === [])) { return true; } @@ -175,7 +175,6 @@ protected function test($rule, $valueToTest, $fieldName = 'item'): bool $param = $ruleParams; } - if (strpos($currentRule, ':') !== false && strpos($currentRule, '|') === false) { $ruleParts = explode(':', $currentRule); @@ -191,7 +190,8 @@ protected function test($rule, $valueToTest, $fieldName = 'item'): bool throw new \Exception("Rule $currentRule does not exist"); } - if (!$valueToTest) { + $isMissing = $valueToTest === null || $valueToTest === '' || ($valueToTest === []); + if ($isMissing) { if ($expandedErrors) { $this->addError($fieldName, str_replace( ['{field}', '{Field}', '{value}'], @@ -247,6 +247,10 @@ protected function test($rule, $valueToTest, $fieldName = 'item'): bool $param = [$param]; } + if (is_bool($valueToTest)) { + $valueToTest = $valueToTest ? '1' : '0'; + } + if (is_float($valueToTest)) { $valueToTest = json_encode($valueToTest, JSON_PRESERVE_ZERO_FRACTION); } @@ -299,7 +303,7 @@ public function validateRule($rule, $valueToTest, $fieldName = 'item'): bool } /** - * Validate form data + * Validate form data with enhanced wildcard support * * @param array $dataSource The data to validate * @param array $validationSet The rules to validate against @@ -308,38 +312,33 @@ public function validateRule($rule, $valueToTest, $fieldName = 'item'): bool */ public function validate(array $dataSource, array $validationSet) { - // clear previous errors $this->errors = []; - $output = []; - foreach ($validationSet as $itemToValidate => $userRules) { - if (empty($userRules)) { - $output[$itemToValidate] = Anchor::deepGetDot($dataSource, $itemToValidate); - + foreach ($validationSet as $fieldPath => $rules) { + if (empty($rules)) { + $output[$fieldPath] = $this->getValue($dataSource, $fieldPath); continue; } - $endsWithWildcard = substr($itemToValidate, -1) === '*'; - $itemToValidate = $endsWithWildcard ? substr($itemToValidate, 0, -1) : $itemToValidate; - - $value = Anchor::deepGetDot($dataSource, $itemToValidate); - - if (!$this->test($userRules, $value, $itemToValidate)) { - $output = false; - } elseif ($output !== false && !$endsWithWildcard) { - if ( - (is_array($userRules) && in_array('optional', $userRules)) - || (is_string($userRules) && strpos($userRules, 'optional') !== false) - ) { - if (Anchor::deepGetDot($dataSource, $itemToValidate) !== null) { - $output = Anchor::deepSetDot($output, $itemToValidate, $value); + // Check for wildcard in field path + if (strpos($fieldPath, '*') !== false) { + $result = $this->validateWildcardPath($dataSource, $fieldPath, $rules); + if ($result === false) { + $output = false; + } + } else { + // Normal validation for fields without wildcards + $value = $this->getValue($dataSource, $fieldPath); + + if (!$this->test($rules, $value, $fieldPath)) { + $output = false; + } elseif ($output !== false) { + if ($this->isOptional($rules) && $value === null) { + continue; } - - continue; + $output = $this->setValue($output, $fieldPath, $value); } - - $output = Anchor::deepSetDot($output, $itemToValidate, $value); } } @@ -434,17 +433,22 @@ public function message($field, ?string $message = null) } /** - * Add validation error + * Add validation error with enhanced path support * @param string $field The field that has an error * @param string $error The error message */ - public function addError(string $field, string $error) + public function addError(string $field, string $error): void { if (!isset($this->errors[$field])) { $this->errors[$field] = []; } - array_push($this->errors[$field], $error); + if (is_array($this->errors[$field])) { + $this->errors[$field][] = $error; + } else { + // Compatibility with original format + $this->errors[$field] = $error; + } } /** @@ -464,4 +468,134 @@ public function errors(): array { return $this->errors; } + + /** + * Validate a path that contains wildcards + */ + protected function validateWildcardPath(array $dataSource, string $fieldPath, $rules) + { + $parts = explode('.', $fieldPath); + $wildcardIndex = array_search('*', $parts); + + if ($wildcardIndex === false) { + // No wildcard found, fallback to normal validation + $value = $this->getValue($dataSource, $fieldPath); + return $this->test($rules, $value, $fieldPath); + } + + // Build base path and remaining path + $basePath = implode('.', array_slice($parts, 0, $wildcardIndex)); + $remainingPath = implode('.', array_slice($parts, $wildcardIndex + 1)); + + $baseValue = $this->getValue($dataSource, $basePath); + + if (!is_array($baseValue)) { + if ($this->isOptional($rules)) { + return true; // Optional and not array, OK + } + return false; // Required but not array, error + } + + $allValid = true; + + foreach ($baseValue as $index => $item) { + $currentPath = $basePath . '.' . $index . ($remainingPath ? '.' . $remainingPath : ''); + + if ($remainingPath) { + // If remaining has more wildcards, recurse + if (strpos($remainingPath, '*') !== false) { + $subResult = $this->validateWildcardPath([$index => $item], $index . '.' . $remainingPath, $rules); + if (!$subResult) { + $allValid = false; + } + } else { + // Normal getValue for path without more wildcards + $currentValue = $this->getValue($item, $remainingPath); + if (!$this->test($rules, $currentValue, $currentPath)) { + $allValid = false; + } + } + } else { + // No remainingPath, validate item itself + if (!$this->test($rules, $item, $currentPath)) { + $allValid = false; + } + } + } + + return $allValid; + } + + /** + * Check if a rule is optional + */ + protected function isOptional($rules): bool + { + if (is_array($rules)) { + return in_array('optional', $rules); + } + + if (is_string($rules)) { + return strpos($rules, 'optional') !== false; + } + + return false; + } + + /** + * Get value using dot notation with fallback + */ + protected function getValue($data, string $path) + { + if (class_exists('\Leaf\Anchor')) { + return \Leaf\Anchor::deepGetDot($data, $path); + } + + // Manual fallback for dot notation + if (strpos($path, '.') === false) { + return $data[$path] ?? null; + } + + $parts = explode('.', $path); + $current = $data; + + foreach ($parts as $part) { + if (!is_array($current) || !array_key_exists($part, $current)) { + return null; + } + $current = $current[$part]; + } + + return $current; + } + + /** + * Set value using dot notation with fallback + */ + protected function setValue(array $data, string $path, $value): array + { + if (class_exists('\Leaf\Anchor')) { + return \Leaf\Anchor::deepSetDot($data, $path, $value); + } + + // Manual fallback for dot notation + if (strpos($path, '.') === false) { + $data[$path] = $value; + return $data; + } + + $parts = explode('.', $path); + $current = &$data; + + for ($i = 0; $i < count($parts) - 1; $i++) { + $part = $parts[$i]; + if (!isset($current[$part]) || !is_array($current[$part])) { + $current[$part] = []; + } + $current = &$current[$part]; + } + + $current[end($parts)] = $value; + return $data; + } } diff --git a/tests/array.test.php b/tests/array.test.php index 5d433ee..66bfae2 100644 --- a/tests/array.test.php +++ b/tests/array.test.php @@ -62,3 +62,177 @@ expect($data)->toBeArray(); }); + +test('wildcard validation works with simple array of strings', function () { + $data = ['tags' => ['php', 'laravel', 'leaf']]; + $rules = ['tags.*' => 'string']; + + $result = validator()->validate($data, $rules); + + expect($result)->not()->toBe(false); + expect(validator()->errors())->toBeEmpty(); +}); + +test('wildcard validation fails when array contains invalid values', function () { + $data = ['tags' => ['php', 123, 'leaf']]; + $rules = ['tags.*' => 'string']; + + $result = validator()->validate($data, $rules); + + expect($result)->toBe(false); + expect(validator()->errors())->toHaveKey('tags.1'); +}); + +test('wildcard validation works with nested arrays', function () { + $data = [ + 'users' => [ + ['name' => 'John', 'email' => 'john@example.com'], + ['name' => 'Jane', 'email' => 'jane@example.com'] + ] + ]; + $rules = [ + 'users.*' => 'array', + 'users.*.name' => 'string', + 'users.*.email' => 'email' + ]; + + $result = validator()->validate($data, $rules); + + expect($result)->not()->toBe(false); + expect(validator()->errors())->toBeEmpty(); +}); + +test('wildcard validation fails with invalid nested array data', function () { + $data = [ + 'users' => [ + ['name' => 'John', 'email' => 'john@example.com'], + ['name' => 123, 'email' => 'invalid-email'] + ] + ]; + $rules = [ + 'users.*' => 'array', + 'users.*.name' => 'string', + 'users.*.email' => 'email' + ]; + + $result = validator()->validate($data, $rules); + + expect($result)->toBe(false); + expect(validator()->errors())->toHaveKey('users.1.name'); + expect(validator()->errors())->toHaveKey('users.1.email'); +}); + +test('multiple wildcard levels work correctly', function () { + $data = [ + 'categories' => [ + 'tech' => [ + ['title' => 'PHP Tutorial', 'tags' => ['php', 'web']], + ['title' => 'Laravel Guide', 'tags' => ['laravel', 'framework']] + ], + 'design' => [ + ['title' => 'UI Design', 'tags' => ['ui', 'design']], + ['title' => 'UX Principles', 'tags' => ['ux', 'design']] + ] + ] + ]; + $rules = [ + 'categories.*.*' => 'array', + 'categories.*.*.title' => 'string', + 'categories.*.*.tags' => 'array', + 'categories.*.*.tags.*' => 'string' + ]; + + $result = validator()->validate($data, $rules); + + expect($result)->not()->toBe(false); + expect(validator()->errors())->toBeEmpty(); +}); + +test('wildcard validation with optional fields', function () { + $data = [ + 'products' => [ + ['name' => 'Product 1', 'description' => 'Description 1'], + ['name' => 'Product 2'], + ['name' => 'Product 3', 'description' => null] + ] + ]; + $rules = [ + 'products.*' => 'array', + 'products.*.name' => 'string', + 'products.*.description' => 'optional|string' + ]; + + $result = validator()->validate($data, $rules); + + expect($result)->not()->toBe(false); + expect(validator()->errors())->toBeEmpty(); +}); + +test('wildcard validation with array<> syntax', function () { + $data = [ + 'users' => [ + ['emails' => ['user1@example.com', 'user1alt@example.com']], + ['emails' => ['user2@example.com']] + ] + ]; + $rules = [ + 'users.*' => 'array', + 'users.*.emails' => 'array' + ]; + + $result = validator()->validate($data, $rules); + + expect($result)->not()->toBe(false); + expect(validator()->errors())->toBeEmpty(); +}); + +test('wildcard validation fails with invalid array<> content', function () { + $data = [ + 'users' => [ + ['emails' => ['user1@example.com', 'invalid-email']], + ['emails' => ['user2@example.com']] + ] + ]; + $rules = [ + 'users.*' => 'array', + 'users.*.emails' => 'array' + ]; + + $result = validator()->validate($data, $rules); + + expect($result)->toBe(false); + expect(validator()->errors())->toHaveKey('users.0.emails'); +}); + +test('deep nested wildcards with mixed data types', function () { + $data = [ + 'companies' => [ + [ + 'name' => 'Company A', + 'departments' => [ + ['name' => 'Engineering', 'budget' => 100000], + ['name' => 'Marketing', 'budget' => 50000] + ] + ], + [ + 'name' => 'Company B', + 'departments' => [ + ['name' => 'Sales', 'budget' => 75000] + ] + ] + ] + ]; + $rules = [ + 'companies.*' => 'array', + 'companies.*.name' => 'string', + 'companies.*.departments' => 'array', + 'companies.*.departments.*' => 'array', + 'companies.*.departments.*.name' => 'string', + 'companies.*.departments.*.budget' => 'number' + ]; + + $result = validator()->validate($data, $rules); + + expect($result)->not()->toBe(false); + expect(validator()->errors())->toBeEmpty(); +});