2025-01-11
Понадобилось мне провалидировать массив и, до кучи, указать, в каком элементе массива возникла ошибка.
Код, выглядит, примерно так:
class Form extends Model {
public $data;
public function rules(): array
{
return [
['data', 'required',],
[
'data',
RangeValidator::class,
'range' => [1,2,3,4,5,6,7,8,9,0],
'allowArray' => true,
'message' => '{attribute} must be between 0 and 9.',
],
];
}
}
И вроде всё уже хорошо, валидация работает:
$model = new Form();
$model->load([1,11,3], '');
$model->validate(); // false
$model->getErrors(); // [
// 'data' => ['Data must be between 0 and 9.']
// ]
Но вот не указано в каком именно элементе произошла ошибка.
В yii2 есть встроенный валидатор
EachValidator который позволяет применить правило валидации
к каждому элементу массива:
class Form extends Model {
public $data;
public function rules(): array
{
return [
['data', 'required',],
[
'data',
EachValidator::class,
'rule' => [
RangeValidator::class,
'range' => [1,2,3,4,5,6,7,8,9,0],
'message' => '{attribute} must be between 0 and 9.',
],
],
];
}
}
Но вывод сообщения об ошибке от этого не изменится.
Фронт у нас уже умеет конвертировать "плоские" ошибки во вложенные:
// вот такое сообщение
[
'data_1' => ['Data must be between 0 and 9.'],
]
// превратит в такое:
[
'data' => [
1 => ['Data must be between 0 and 9.'],
],
]
// и подсветит соответствующий элемент в форме.
Но вот писать каждый раз кастомный способ валидации поля мне не
хочется, а хочется чтобы EachValidator сам добавлял в
название атрибута индекс элемента который не прошёл валидацию.
Вариант с расширением и переписыванием метода
validateAttribute мне очень не понравился тем, что,
фактически, мне нужно скопировать метод целиком и дописать в него оду
строчку:
public function validateAttribute($model, $attribute)
{
// ...
$attribute = sprintf('%s_%d', $attribute, $key); // <-- вот эта строчка
if ($this->allowMessageFromRule) {
$validationErrors = $dynamicModel->getErrors($attribute);
$model->addErrors([$attribute => $validationErrors]);
} else {
$this->addError($model, $attribute, $this->message, ['value' => $v]);
}
// ...
}
В какой-то момент, при очередном перечитывании этого метода, мне пришла в голову идея: что если бы я мог знать какой элемент сейчас проходит валидацию, то я мог бы где-то в другом месте изменить название атрибута добавив к нему индекс элемента который не прошёл валидацию?
Сделать это можно подменив значение валидируемой модели на объект, который бы нам и сообщил, при необходимости, какой элемент сейчас не прошёл валидацию.
В самом начале метода есть проверка на тип валидируемого поля:
public function validateAttribute($model, $attribute)
{
// ...
if (!is_array($arrayOfValues) && !$arrayOfValues instanceof \ArrayAccess)
// ...
}
Под условие !$arrayOfValues instanceof \ArrayAccess
отлично подходит ArrayIterator, а ещё у него есть метод
key() возвращающий индекс текущего элемента.
Так, а как мне подменить array в модели на
ArrayIterator? Писать в каждую модель какие-то
преобразования можно, конечно, но это:
Очень быстро мне пришла в голову идея с прокси-объектом: мы можем
подменить исходную модель на прокси, который подменит array
на ArrayIterator, а так же, в нужный момент, сможет
изменить имя атрибута (т.е. в момент добавления сообщения об
ошибке).
class ProxyModel extends Model {
private ArrayIterator $data;
public function __construct(private Model $model, private string $attribute, $config = []) {
$this->data = new ArrayIterator($model->{$attribute};);
}
public function __get($name): mixed
{
return $this->data;
}
public function __set($name, $value): void
{
$this->model->{$name} = $value->getArrayCopy();
}
public function addError($attribute, $error = '')
{
$attribute = sprintf('%s_%d', $attribute, $this->data->key());
$this->model->addError($attribute, $error);
}
}
Теперь можно расширить EachValidator без переписывания
методов:
class EachItemValidator extends EachValidator
{
public function validateAttribute($model, $attribute): void
{
$proxyModel = new ProxyModel($model, $attribute);
parent::validateAttribute($proxyModel, $attribute);
}
}
Перепишем модель на использование нового валидатора:
class Form extends Model {
public $data;
public function rules(): array
{
return [
['data', 'required',],
[
'data',
EachItemValidator::class,
'rule' => [
RangeValidator::class,
'range' => [1,2,3,4,5,6,7,8,9,0],
'message' => '{attribute} must be between 0 and 9.',
],
],
];
}
}
Результат будет такой:
$model = new Form();
$model->load([1,11,3], '');
$model->validate(); // false
$model->getErrors(); // [
// 'data_1' => ['Data must be between 0 and 9.']
// ]
Единственная проблема - это проверка поля на прохождение валидации
будет возвращать true:
$model = new Form();
$model->load([1,11,3], '');
$model->validate(); // false
$model->hasErrors(); // true
$model->hasErrors('data'); // false
Комментарии
Комментарии на GitHub