This commit is contained in:
Egor Isaev 2026-06-23 14:17:26 +03:00
parent b65603b4d0
commit e6e8844f2b
6 changed files with 475 additions and 3 deletions

View File

@ -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]].

View File

@ -8,4 +8,4 @@
?>
<?php var_dump($todo); ?>
<?php var_dump($created); ?>

View File

@ -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)

View File

@ -0,0 +1,218 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description Validation.php
* @copyright (c) 23/06/2026
*/
namespace System\Classes;
/**
* Валидация входных данных по декларативным правилам.
* Источник данных обычно Request::post()/query().
*
* Использование:
* $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();
* }
*/
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<string,string> Шаблоны сообщений с плейсхолдерами :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 === [];
}
}

View File

@ -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
);
}
}

View File

@ -0,0 +1,211 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\MyException;
use System\Classes\Validation;
class ValidationTest extends TestCase
{
public function testValidDataPasses(): void
{
$v = Validation::factory(['email' => '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());
}
}