diff --git a/.claude/memory/feedback_naming_phpdoc.md b/.claude/memory/feedback_naming_phpdoc.md new file mode 100644 index 0000000..621516a --- /dev/null +++ b/.claude/memory/feedback_naming_phpdoc.md @@ -0,0 +1,17 @@ +--- +name: feedback-naming-phpdoc +description: Правила именования PHP (snake_case переменные, camelCase методы, PascalCase классы) и обязательный PHPDoc везде +metadata: + type: feedback +--- + +В проекте Bicycle соблюдать конвенции именования и документирования PHP: + +- **Переменные** — `$snake_case`, имена понятные и отражающие, что в них хранится (не `$x`, `$tmp`). +- **Функции/методы** — `camelCase`, с маленькой буквы, имена понятные (`getResponse`, `detectUri`, `isAjax`). +- **Классы** — `PascalCase` (CamelCase с большой буквы). +- **PHPDoc** — писать везде: на классах, методах (с `@param`/`@return`/`@throws`) и свойствах. + +**Why:** Пользователь явно задал эти правила и попросил исправить весь код под них. + +**How to apply:** При создании или правке любого PHP-кода сразу применять эти правила без напоминаний. Исключение: классы `HTTPException_302/403/404` намеренно содержат суффикс `_код` — это load-bearing паттерн для `HTTPException::factory()`, не переименовывать. Связано с [[feedback-update-claude-md]] и [[project-bicycle-overview]]. diff --git a/App/view/Index/index.html b/App/view/Index/index.html index ad16d76..175197c 100644 --- a/App/view/Index/index.html +++ b/App/view/Index/index.html @@ -8,4 +8,4 @@ ?> - + diff --git a/CLAUDE.md b/CLAUDE.md index 0c59003..17ed60c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,7 @@ PhpStorm может показывать предупреждение «Namespac | `System\Classes\Cookie` | Статический хелпер для работы с куками: `get()`, `set()`, `delete()` | | `System\Classes\Session` | Синглтон сессии: `instance(string $name)`, `get/set/delete/destroy/regenerate` | | `System\Classes\Config` | Статический конфиг: lazy-load `App/config/config.php` + merge `config.local.php` | +| `System\Classes\Validation` | Валидация входных данных по декларативным правилам; `factory()`, `rule()`, `check()`, `errors()` | | `System\Classes\HTTP` | `redirect()`, `requestHeaders()` | | `System\Classes\HTTP\Header` | Extends `ArrayObject`; `send()`, `__toString()` → RFC-формат | | `System\Classes\HTTP\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` | @@ -181,6 +182,30 @@ Config::set('foo', ['bar' => 'baz']); // переопредел `config.local.php` исключён из git (`.gitignore`) — используется для переопределений на конкретном хосте. +### Validation (System\Classes\Validation) + +Валидация входных данных (обычно `Request::post()`/`query()`) по декларативным правилам. + +```php +use System\Classes\Validation; + +$validation = Validation::factory($request->post()) + ->label('email', 'E-mail') + ->rule('email', 'required') + ->rule('email', 'email') + ->rule('password', 'min_length', [8]) + ->rule('password_confirm', 'matches', ['password']); + +if ($validation->check()) { + // данные валидны +} else { + $errors = $validation->errors(); // [поле => сообщение] + $one = $validation->error('email'); // сообщение одного поля или null +} +``` + +Правила: `required`, `email`, `url`, `numeric`, `digit`, `min_length`, `max_length`, `exact_length`, `matches`, `in`, `regex`. Правила одного поля проверяются по порядку до первой ошибки. Пустое необязательное поле (нет `required`) остальные правила пропускает. Сообщения — шаблоны с плейсхолдерами `:field`/`:param1`/`:param2` (подстановка через `strtr`, как в `MyException`). + ### HTTPException (System\Classes\HTTP\HTTPException) ```php @@ -245,8 +270,9 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap | `tests/Unit/SessionTest.php` | singleton, `get/set/delete/destroy/regenerate/bind` | | `tests/Unit/ConfigTest.php` | `get()`, `set()`, `merge()`, lazy-load | | `tests/Unit/HTTPExceptionTest.php` | `factory()`, subclasses, `getResponse()`, codes | +| `tests/Unit/ValidationTest.php` | правила, `matches`, пропуск пустых, first-error, label/плейсхолдеры, fluent | -**111 тестов, 171 assertion — все проходят.** +**135 тестов, 215 assertion — все проходят.** ### Frontend dependencies (через Composer) diff --git a/System/Classes/Validation.php b/System/Classes/Validation.php new file mode 100644 index 0000000..5994275 --- /dev/null +++ b/System/Classes/Validation.php @@ -0,0 +1,218 @@ +post()) + * ->label('email', 'E-mail') + * ->rule('email', 'required') + * ->rule('email', 'email') + * ->rule('password', 'min_length', [8]) + * ->rule('password_confirm', 'matches', ['password']); + * + * if ($validation->check()) { + * // данные валидны + * } else { + * $errors = $validation->errors(); + * } + */ +class Validation +{ + /** @var array Проверяемые данные [поле => значение] */ + private array $_data = []; + /** @var array Правила [поле => [['rule' => ..., 'params' => [...]], ...]] */ + private array $_rules = []; + /** @var array Человекочитаемые названия полей [поле => подпись] */ + private array $_labels = []; + /** @var array Ошибки после check() [поле => сообщение] */ + private array $_errors = []; + + /** @var array Шаблоны сообщений с плейсхолдерами :field/:param1/:param2 */ + private static array $messages = [ + 'required' => 'Поле «:field» обязательно для заполнения', + 'email' => 'Поле «:field» должно быть корректным e-mail', + 'url' => 'Поле «:field» должно быть корректным URL', + 'numeric' => 'Поле «:field» должно быть числом', + 'digit' => 'Поле «:field» должно содержать только цифры', + 'min_length' => 'Поле «:field» должно быть не короче :param1 символов', + 'max_length' => 'Поле «:field» должно быть не длиннее :param1 символов', + 'exact_length' => 'Поле «:field» должно быть длиной :param1 символов', + 'matches' => 'Поле «:field» должно совпадать с полем «:param1»', + 'in' => 'Поле «:field» содержит недопустимое значение', + 'regex' => 'Поле «:field» имеет неверный формат', + ]; + + /** + * @param array $data Проверяемые данные [поле => значение] + */ + private function __construct(array $data) + { + $this->_data = $data; + } + + /** + * Создаёт экземпляр валидатора над набором данных. + * + * @param array $data Проверяемые данные [поле => значение] + * @return static + */ + public static function factory(array $data): static + { + return new static($data); + } + + /** + * Добавляет правило для поля. Правила одного поля проверяются по порядку, + * до первой ошибки. + * + * @param string $field Имя поля + * @param string $rule Имя правила (см. ключи self::$messages) + * @param array $params Параметры правила (например [8] для min_length) + * @return static + */ + public function rule(string $field, string $rule, array $params = []): static + { + $this->_rules[$field][] = ['rule' => $rule, 'params' => $params]; + return $this; + } + + /** + * Задаёт человекочитаемую подпись поля для сообщений об ошибках. + * + * @param string $field Имя поля + * @param string $label Подпись + * @return static + */ + public function label(string $field, string $label): static + { + $this->_labels[$field] = $label; + return $this; + } + + /** + * Прогоняет все правила. Заполняет список ошибок. + * + * @return bool true, если ошибок нет + */ + public function check(): bool + { + $this->_errors = []; + + foreach ($this->_rules as $field => $rules) { + $value = $this->_data[$field] ?? null; + + foreach ($rules as $rule) { + $name = $rule['rule']; + $params = $rule['params']; + + // Пустое необязательное поле — остальные правила пропускаем. + if ($name !== 'required' && self::isEmpty($value)) { + continue; + } + + if (!$this->execute($name, $value, $params)) { + $this->_errors[$field] = $this->message($field, $name, $params); + break; + } + } + } + + return $this->_errors === []; + } + + /** + * Проверяет одно значение одним правилом. + * + * @param string $rule Имя правила + * @param mixed $value Значение поля + * @param array $params Параметры правила + * @return bool + * @throws MyException Если правило неизвестно + */ + private function execute(string $rule, mixed $value, array $params): bool + { + return match ($rule) { + 'required' => !self::isEmpty($value), + 'email' => filter_var($value, FILTER_VALIDATE_EMAIL) !== false, + 'url' => filter_var($value, FILTER_VALIDATE_URL) !== false, + 'numeric' => is_numeric($value), + 'digit' => ctype_digit((string) $value), + 'min_length' => mb_strlen((string) $value) >= (int) ($params[0] ?? 0), + 'max_length' => mb_strlen((string) $value) <= (int) ($params[0] ?? 0), + 'exact_length' => mb_strlen((string) $value) === (int) ($params[0] ?? 0), + 'matches' => $value === ($this->_data[$params[0] ?? ''] ?? null), + 'in' => in_array($value, (array) ($params[0] ?? []), true), + 'regex' => preg_match($params[0] ?? '//', (string) $value) === 1, + default => throw new MyException( + 'Неизвестное правило валидации: :rule', [':rule' => $rule] + ), + }; + } + + /** + * Формирует сообщение об ошибке с подстановкой подписи поля и параметров. + * + * @param string $field Имя поля + * @param string $rule Имя правила + * @param array $params Параметры правила + * @return string + */ + private function message(string $field, string $rule, array $params): string + { + $template = self::$messages[$rule] ?? 'Поле «:field» указано неверно'; + + // Для matches параметр — имя поля, показываем его подпись, если задана. + $param1 = $params[0] ?? ''; + if ($rule === 'matches') { + $param1 = $this->_labels[$param1] ?? $param1; + } + + return strtr($template, [ + ':field' => $this->_labels[$field] ?? $field, + ':param1' => is_scalar($param1) ? (string) $param1 : '', + ':param2' => (string) ($params[1] ?? ''), + ]); + } + + /** + * Возвращает все ошибки после check(). + * + * @return array [поле => сообщение] + */ + public function errors(): array + { + return $this->_errors; + } + + /** + * Возвращает сообщение об ошибке для одного поля. + * + * @param string $field Имя поля + * @return string|null null, если ошибки нет + */ + public function error(string $field): ?string + { + return $this->_errors[$field] ?? null; + } + + /** + * Проверяет, считается ли значение пустым (null, '' или []). + * + * @param mixed $value Значение + * @return bool + */ + private static function isEmpty(mixed $value): bool + { + return $value === null || $value === '' || $value === []; + } +} diff --git a/System/Classes/View.php b/System/Classes/View.php index 58c6586..de57c9b 100644 --- a/System/Classes/View.php +++ b/System/Classes/View.php @@ -91,7 +91,7 @@ class View ob_end_clean(); throw new MyException( - "Ошибка при render файла: {$e->getFile()} на строке {$e->getLine()} - {$e->getMessage()}", 0, $e + "Ошибка при render файла: {$e->getFile()} на строке {$e->getLine()} - {$e->getMessage()}", null, 0, $e ); } } diff --git a/tests/Unit/ValidationTest.php b/tests/Unit/ValidationTest.php new file mode 100644 index 0000000..0bcb318 --- /dev/null +++ b/tests/Unit/ValidationTest.php @@ -0,0 +1,211 @@ + 'a@b.com', 'age' => '20']) + ->rule('email', 'email') + ->rule('age', 'digit'); + + $this->assertTrue($v->check()); + $this->assertSame([], $v->errors()); + } + + public function testRequiredFailsOnEmpty(): void + { + $v = Validation::factory(['name' => ''])->rule('name', 'required'); + + $this->assertFalse($v->check()); + $this->assertArrayHasKey('name', $v->errors()); + } + + public function testRequiredFailsOnMissingKey(): void + { + $v = Validation::factory([])->rule('name', 'required'); + + $this->assertFalse($v->check()); + $this->assertArrayHasKey('name', $v->errors()); + } + + public function testEmptyOptionalFieldSkipsOtherRules(): void + { + // Поле пустое, но required нет — email-правило не должно срабатывать. + $v = Validation::factory(['email' => ''])->rule('email', 'email'); + + $this->assertTrue($v->check()); + $this->assertSame([], $v->errors()); + } + + public function testStopsAtFirstErrorPerField(): void + { + // required и email оба провалились бы, но сообщение должно быть от required. + $v = Validation::factory(['email' => '']) + ->rule('email', 'required') + ->rule('email', 'email'); + + $this->assertFalse($v->check()); + $this->assertStringContainsString('обязательно', $v->error('email')); + } + + public function testEmailRule(): void + { + $bad = Validation::factory(['email' => 'not-an-email'])->rule('email', 'email'); + $this->assertFalse($bad->check()); + + $ok = Validation::factory(['email' => 'user@example.com'])->rule('email', 'email'); + $this->assertTrue($ok->check()); + } + + public function testUrlRule(): void + { + $bad = Validation::factory(['site' => 'example'])->rule('site', 'url'); + $this->assertFalse($bad->check()); + + $ok = Validation::factory(['site' => 'https://example.com'])->rule('site', 'url'); + $this->assertTrue($ok->check()); + } + + public function testNumericRule(): void + { + $this->assertTrue(Validation::factory(['n' => '3.14'])->rule('n', 'numeric')->check()); + $this->assertFalse(Validation::factory(['n' => '12abc'])->rule('n', 'numeric')->check()); + } + + public function testDigitRule(): void + { + $this->assertTrue(Validation::factory(['n' => '42'])->rule('n', 'digit')->check()); + $this->assertFalse(Validation::factory(['n' => '3.14'])->rule('n', 'digit')->check()); + } + + public function testMinLengthRule(): void + { + $this->assertFalse(Validation::factory(['p' => '123'])->rule('p', 'min_length', [8])->check()); + $this->assertTrue(Validation::factory(['p' => '12345678'])->rule('p', 'min_length', [8])->check()); + } + + public function testMaxLengthRule(): void + { + $this->assertFalse(Validation::factory(['p' => '123456'])->rule('p', 'max_length', [4])->check()); + $this->assertTrue(Validation::factory(['p' => '1234'])->rule('p', 'max_length', [4])->check()); + } + + public function testExactLengthRule(): void + { + $this->assertTrue(Validation::factory(['code' => '1234'])->rule('code', 'exact_length', [4])->check()); + $this->assertFalse(Validation::factory(['code' => '12345'])->rule('code', 'exact_length', [4])->check()); + } + + public function testMinLengthCountsMultibyte(): void + { + // 4 кириллических символа — должно проходить min_length(4). + $this->assertTrue(Validation::factory(['p' => 'тест'])->rule('p', 'min_length', [4])->check()); + } + + public function testMatchesRule(): void + { + $ok = Validation::factory(['pass' => 'secret', 'confirm' => 'secret']) + ->rule('confirm', 'matches', ['pass']); + $this->assertTrue($ok->check()); + + $bad = Validation::factory(['pass' => 'secret', 'confirm' => 'other']) + ->rule('confirm', 'matches', ['pass']); + $this->assertFalse($bad->check()); + } + + public function testInRule(): void + { + $ok = Validation::factory(['role' => 'admin']) + ->rule('role', 'in', [['admin', 'user']]); + $this->assertTrue($ok->check()); + + $bad = Validation::factory(['role' => 'root']) + ->rule('role', 'in', [['admin', 'user']]); + $this->assertFalse($bad->check()); + } + + public function testRegexRule(): void + { + $ok = Validation::factory(['slug' => 'hello-world']) + ->rule('slug', 'regex', ['/^[a-z\-]+$/']); + $this->assertTrue($ok->check()); + + $bad = Validation::factory(['slug' => 'Hello World']) + ->rule('slug', 'regex', ['/^[a-z\-]+$/']); + $this->assertFalse($bad->check()); + } + + public function testLabelAppearsInMessage(): void + { + $v = Validation::factory(['email' => 'bad']) + ->label('email', 'E-mail') + ->rule('email', 'email'); + + $v->check(); + $this->assertStringContainsString('E-mail', $v->error('email')); + } + + public function testMatchesMessageUsesParamLabel(): void + { + $v = Validation::factory(['pass' => 'a', 'confirm' => 'b']) + ->label('pass', 'Пароль') + ->rule('confirm', 'matches', ['pass']); + + $v->check(); + $this->assertStringContainsString('Пароль', $v->error('confirm')); + } + + public function testParamPlaceholderInMessage(): void + { + $v = Validation::factory(['p' => '1'])->rule('p', 'min_length', [8]); + $v->check(); + $this->assertStringContainsString('8', $v->error('p')); + } + + public function testErrorReturnsNullForValidField(): void + { + $v = Validation::factory(['email' => 'a@b.com'])->rule('email', 'email'); + $v->check(); + $this->assertNull($v->error('email')); + } + + public function testMultipleFieldsCollectErrors(): void + { + $v = Validation::factory(['email' => 'bad', 'age' => 'x']) + ->rule('email', 'email') + ->rule('age', 'digit'); + + $this->assertFalse($v->check()); + $this->assertCount(2, $v->errors()); + } + + public function testFluentReturnsStatic(): void + { + $v = Validation::factory([]); + $this->assertSame($v, $v->rule('a', 'required')); + $this->assertSame($v, $v->label('a', 'A')); + } + + public function testUnknownRuleThrows(): void + { + $this->expectException(MyException::class); + Validation::factory(['a' => 'x'])->rule('a', 'no_such_rule')->check(); + } + + public function testCheckIsRepeatableAndResetsErrors(): void + { + $v = Validation::factory(['email' => 'bad'])->rule('email', 'email'); + $this->assertFalse($v->check()); + $this->assertCount(1, $v->errors()); + + // Повторный вызов не накапливает ошибки. + $this->assertFalse($v->check()); + $this->assertCount(1, $v->errors()); + } +}