dev
This commit is contained in:
parent
b65603b4d0
commit
e6e8844f2b
17
.claude/memory/feedback_naming_phpdoc.md
Normal file
17
.claude/memory/feedback_naming_phpdoc.md
Normal 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]].
|
||||
@ -8,4 +8,4 @@
|
||||
|
||||
?>
|
||||
|
||||
<?php var_dump($todo); ?>
|
||||
<?php var_dump($created); ?>
|
||||
|
||||
28
CLAUDE.md
28
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)
|
||||
|
||||
|
||||
218
System/Classes/Validation.php
Normal file
218
System/Classes/Validation.php
Normal 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 === [];
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
211
tests/Unit/ValidationTest.php
Normal file
211
tests/Unit/ValidationTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user