Passed
Push — html-like-rules ( c01197 )
by Dmitriy
07:51
created

FormModel::isValidated()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Form;
6
7
use Closure;
8
use ReflectionClass;
9
use InvalidArgumentException;
10
use Yiisoft\Strings\Inflector;
11
use Yiisoft\Strings\StringHelper;
12
use Yiisoft\Validator\Rule\Required;
13
use Yiisoft\Validator\ValidatorFactoryInterface;
14
15
use function array_key_exists;
16
use function array_merge;
17
use function explode;
18
use function get_object_vars;
19
use function is_subclass_of;
20
use function reset;
21
use function sprintf;
22
use function strpos;
23
24
/**
25
 * Form model represents an HTML form: its data, validation and presentation.
26
 */
27
abstract class FormModel implements FormModelInterface
28
{
29
    private ValidatorFactoryInterface $validatorFactory;
30
31
    private array $attributes;
32
    private array $attributesLabels;
33
    private array $attributesErrors = [];
34
    private ?Inflector $inflector = null;
35
    private bool $validated = false;
36
37 247
    public function __construct(ValidatorFactoryInterface $validatorFactory)
38
    {
39 247
        $this->validatorFactory = $validatorFactory;
40
41 247
        $this->attributes = $this->collectAttributes();
42 246
        $this->attributesLabels = $this->attributeLabels();
43 246
    }
44
45 57
    public function isAttributeRequired(string $attribute): bool
46
    {
47 57
        $validators = $this->rules()[$attribute] ?? [];
48
49 57
        foreach ($validators as $validator) {
50 27
            if ($validator instanceof Required) {
51 19
                return true;
52
            }
53
        }
54
55 41
        return false;
56
    }
57
58 223
    public function getAttributeValue(string $attribute)
59
    {
60 223
        return $this->readProperty($attribute);
61
    }
62
63 7
    public function attributeLabels(): array
64
    {
65 7
        return [];
66
    }
67
68 64
    public function attributeHint(string $attribute): string
69
    {
70 64
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
71 64
        if ($nested !== null) {
72 1
            return $this->readProperty($attribute)->attributeHint($nested);
73
        }
74
75 64
        $hints = $this->attributeHints();
76
77 64
        return $hints[$attribute] ?? '';
78
    }
79
80 1
    public function attributeHints(): array
81
    {
82 1
        return [];
83
    }
84
85 130
    public function attributeLabel(string $attribute): string
86
    {
87 130
        return $this->attributesLabels[$attribute] ?? $this->getAttributeLabel($attribute);
88
    }
89
90
    /**
91
     * @return string Returns classname without a namespace part or empty string when class is anonymous
92
     */
93 235
    public function formName(): string
94
    {
95 235
        if (strpos(static::class, '@anonymous') !== false) {
96 4
            return '';
97
        }
98
99 232
        $className = strrchr(static::class, '\\');
100 232
        if ($className === false) {
101 1
            return static::class;
102
        }
103
104 231
        return substr($className, 1);
105
    }
106
107 1
    public function hasAttribute(string $attribute): bool
108
    {
109 1
        return array_key_exists($attribute, $this->attributes);
110
    }
111
112 1
    public function error(string $attribute): array
113
    {
114 1
        return $this->attributesErrors[$attribute] ?? [];
115
    }
116
117 2
    public function errors(): array
118
    {
119 2
        return $this->attributesErrors;
120
    }
121
122 4
    public function errorSummary(bool $showAllErrors): array
123
    {
124 4
        $lines = [];
125 4
        $errors = $showAllErrors ? $this->errors() : [$this->firstErrors()];
126
127 4
        foreach ($errors as $error) {
128 4
            $lines = array_merge($lines, $error);
129
        }
130
131 4
        return $lines;
132
    }
133
134 65
    public function firstError(string $attribute): string
135
    {
136 65
        if (empty($this->attributesErrors[$attribute])) {
137 53
            return '';
138
        }
139
140 15
        return reset($this->attributesErrors[$attribute]);
141
    }
142
143 3
    public function firstErrors(): array
144
    {
145 3
        if (empty($this->attributesErrors)) {
146 2
            return [];
147
        }
148
149 1
        $errors = [];
150
151 1
        foreach ($this->attributesErrors as $name => $es) {
152 1
            if (!empty($es)) {
153 1
                $errors[$name] = reset($es);
154
            }
155
        }
156
157 1
        return $errors;
158
    }
159
160 71
    public function hasErrors(?string $attribute = null): bool
161
    {
162 71
        return $attribute === null ? !empty($this->attributesErrors) : isset($this->attributesErrors[$attribute]);
163
    }
164
165
    /**
166
     * @param array $data
167
     * @param string|null $formName
168
     *
169
     * @return bool
170
     */
171 14
    public function load(array $data, ?string $formName = null): bool
172
    {
173 14
        $scope = $formName ?? $this->formName();
174
175
        /**
176
         * @psalm-var array<string,mixed>
177
         */
178 14
        $values = [];
179
180 14
        if ($scope === '' && !empty($data)) {
181 2
            $values = $data;
182 13
        } elseif (isset($data[$scope])) {
183 12
            $values = $data[$scope];
184
        }
185
186 14
        foreach ($values as $name => $value) {
187 14
            $this->setAttribute($name, $value);
188
        }
189
190 14
        return $values !== [];
191
    }
192
193 14
    public function setAttribute(string $name, $value): void
194
    {
195 14
        [$realName] = $this->getNestedAttribute($name);
196 14
        if (isset($this->attributes[$realName])) {
197 13
            switch ($this->attributes[$realName]) {
198 13
                case 'bool':
199 3
                    $this->writeProperty($name, (bool) $value);
200 3
                    break;
201 13
                case 'float':
202 1
                    $this->writeProperty($name, (float) $value);
203 1
                    break;
204 13
                case 'int':
205 2
                    $this->writeProperty($name, (int) $value);
206 2
                    break;
207 13
                case 'string':
208 13
                    $this->writeProperty($name, (string) $value);
209 13
                    break;
210
                default:
211 1
                    $this->writeProperty($name, $value);
212 1
                    break;
213
            }
214
        }
215 14
    }
216
217 22
    public function validate(): bool
218
    {
219 22
        $this->clearErrors();
220
221 22
        $rules = $this->rules();
222
223 22
        if (!empty($rules)) {
224 22
            $results = $this->validatorFactory
225 22
                ->create($rules)
226 22
                ->validate($this);
227
228 22
            foreach ($results as $attribute => $result) {
229 22
                if ($result->isValid() === false) {
230 20
                    $this->addErrors([$attribute => $result->getErrors()]);
231
                }
232
            }
233
        }
234
235 22
        $this->validated = true;
236
237 22
        return !$this->hasErrors();
238
    }
239
240 2
    public function addError(string $attribute, string $error): void
241
    {
242 2
        $this->attributesErrors[$attribute][] = $error;
243 2
    }
244
245
    /**
246
     * Returns the validation rules for attributes.
247
     *
248
     * Validation rules are used by {@see \Yiisoft\Validator\Validator} to check if attribute values are valid.
249
     * Child classes may override this method to declare different validation rules.
250
     *
251
     * Each rule is an array with the following structure:
252
     *
253
     * ```php
254
     * public function rules(): array
255
     * {
256
     *     return [
257
     *         'login' => $this->loginRules()
258
     *     ];
259
     * }
260
     *
261
     * private function loginRules(): array
262
     * {
263
     *   return [
264
     *       new \Yiisoft\Validator\Rule\Required(),
265
     *       (new \Yiisoft\Validator\Rule\HasLength())
266
     *       ->min(4)
267
     *       ->max(40)
268
     *       ->tooShortMessage('Is too short.')
269
     *       ->tooLongMessage('Is too long.'),
270
     *       new \Yiisoft\Validator\Rule\Email()
271
     *   ];
272
     * }
273
     * ```
274
     *
275
     * @return array validation rules
276
     */
277
    public function rules(): array
278
    {
279
        return [];
280
    }
281
282
    /**
283
     * @param string[][] $items
284
     */
285 20
    private function addErrors(array $items): void
286
    {
287 20
        foreach ($items as $attribute => $errors) {
288 20
            foreach ($errors as $error) {
289 20
                $this->attributesErrors[$attribute][] = $error;
290
            }
291
        }
292 20
    }
293
294
    /**
295
     * Returns the list of attribute types indexed by attribute names.
296
     *
297
     * By default, this method returns all non-static properties of the class.
298
     *
299
     * @throws \ReflectionException
300
     *
301
     * @return array list of attribute types indexed by attribute names.
302
     */
303 247
    private function collectAttributes(): array
304
    {
305 247
        $class = new ReflectionClass($this);
306 247
        $attributes = [];
307
308 247
        foreach ($class->getProperties() as $property) {
309 243
            if ($property->isStatic()) {
310 16
                continue;
311
            }
312
313 243
            $type = $property->getType();
0 ignored issues
show
Bug introduced by
The method getType() does not exist on ReflectionProperty. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

313
            /** @scrutinizer ignore-call */ 
314
            $type = $property->getType();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
314 243
            if ($type === null) {
315 1
                throw new InvalidArgumentException(sprintf(
316 1
                    'You must specify the type hint for "%s" property in "%s" class.',
317 1
                    $property->getName(),
318 1
                    $property->getDeclaringClass()->getName(),
319
                ));
320
            }
321
322 242
            $attributes[$property->getName()] = $type->getName();
323
        }
324
325 246
        return $attributes;
326
    }
327
328 22
    private function clearErrors(?string $attribute = null): void
329
    {
330 22
        if ($attribute === null) {
331 22
            $this->attributesErrors = [];
332
        } else {
333
            unset($this->attributesErrors[$attribute]);
334
        }
335
336 22
        $this->validated = false;
337 22
    }
338
339 129
    private function getInflector(): Inflector
340
    {
341 129
        if ($this->inflector === null) {
342 129
            $this->inflector = new Inflector();
343
        }
344 129
        return $this->inflector;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->inflector could return the type null which is incompatible with the type-hinted return Yiisoft\Strings\Inflector. Consider adding an additional type-check to rule them out.
Loading history...
345
    }
346
347 130
    private function getAttributeLabel(string $attribute): string
348
    {
349 130
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
350
351 130
        return $nested !== null
352 1
            ? $this->readProperty($attribute)->attributeLabel($nested)
353 130
            : $this->generateAttributeLabel($attribute);
354
    }
355
356
    /**
357
     * Generates a user friendly attribute label based on the give attribute name.
358
     *
359
     * This is done by replacing underscores, dashes and dots with blanks and changing the first letter of each word to
360
     * upper case.
361
     *
362
     * For example, 'department_name' or 'DepartmentName' will generate 'Department Name'.
363
     *
364
     * @param string $name the column name.
365
     *
366
     * @return string the attribute label.
367
     */
368 129
    private function generateAttributeLabel(string $name): string
369
    {
370 129
        return StringHelper::uppercaseFirstCharacterInEachWord(
371 129
            $this->getInflector()->toWords($name)
372
        );
373
    }
374
375 225
    private function readProperty(string $attribute)
376
    {
377 225
        $class = static::class;
378
379 225
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
380
381 225
        if (!property_exists($class, $attribute)) {
382 2
            throw new InvalidArgumentException("Undefined property: \"$class::$attribute\".");
383
        }
384
385 224
        if ($this->isPublicAttribute($attribute)) {
386
            return $nested === null ? $this->$attribute : $this->$attribute->getAttributeValue($nested);
387
        }
388
389 224
        $getter = fn (FormModel $class, $attribute) => $nested === null
390 224
            ? $class->$attribute
391 224
            : $class->$attribute->getAttributeValue($nested);
392 224
        $getter = Closure::bind($getter, null, $this);
393
394
        /**
395
         * @psalm-var Closure $getter
396
         */
397 224
        return $getter($this, $attribute);
398
    }
399
400 13
    private function writeProperty(string $attribute, $value): void
401
    {
402 13
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
403 13
        if ($this->isPublicAttribute($attribute)) {
404
            if ($nested === null) {
405
                $this->$attribute = $value;
406
            } else {
407
                $this->$attribute->setAttribute($attribute, $value);
408
            }
409
        } else {
410 13
            $setter = fn (FormModel $class, $attribute, $value) => $nested === null
411 13
                ? $class->$attribute = $value
412 13
                : $class->$attribute->setAttribute($nested, $value);
413 13
            $setter = Closure::bind($setter, null, $this);
414
415
            /**
416
             * @psalm-var Closure $setter
417
             */
418 13
            $setter($this, $attribute, $value);
419
        }
420 13
    }
421
422 226
    private function isPublicAttribute(string $attribute): bool
423
    {
424 226
        return array_key_exists($attribute, get_object_vars($this));
425
    }
426
427 238
    private function getNestedAttribute(string $attribute): array
428
    {
429 238
        if (strpos($attribute, '.') === false) {
430 238
            return [$attribute, null];
431
        }
432
433 4
        [$attribute, $nested] = explode('.', $attribute, 2);
434
435 4
        if (!is_subclass_of($this->attributes[$attribute], self::class)) {
436
            throw new InvalidArgumentException('Nested attribute can only be of ' . self::class . ' type.');
437
        }
438
439 4
        return [$attribute, $nested];
440
    }
441
442 43
    public function isValidated(): bool
443
    {
444 43
        return $this->validated;
445
    }
446
}
447