Passed
Push — use-stateless-validator ( 690a89 )
by Dmitriy
03:09
created

FormModel   F

Complexity

Total Complexity 72

Size/Duplication

Total Lines 412
Duplicated Lines 0 %

Test Coverage

Coverage 94.22%

Importance

Changes 8
Bugs 0 Features 0
Metric Value
eloc 145
c 8
b 0
f 0
dl 0
loc 412
ccs 163
cts 173
cp 0.9422
rs 2.64
wmc 72

29 Methods

Rating   Name   Duplication   Size   Complexity  
A getAttributeValue() 0 3 1
A writeProperty() 0 19 4
A attributeLabels() 0 3 1
A isPublicAttribute() 0 3 1
A __construct() 0 4 1
A generateAttributeLabel() 0 4 1
A errorSummary() 0 10 3
A validate() 0 19 4
A addErrors() 0 5 3
A errors() 0 3 1
A setAttribute() 0 20 6
A hasErrors() 0 3 2
A collectAttributes() 0 23 4
A firstError() 0 7 2
A attributeLabel() 0 3 1
A attributeHints() 0 3 1
A formName() 0 12 3
A rules() 0 3 1
A error() 0 3 1
A isAttributeRequired() 0 11 3
A clearErrors() 0 9 2
A firstErrors() 0 15 4
A isValidated() 0 3 1
A readProperty() 0 23 5
A getAttributeLabel() 0 7 2
A getNestedAttribute() 0 13 3
A attributeHint() 0 10 2
A hasAttribute() 0 3 1
A load() 0 20 5

How to fix   Complexity   

Complex Class

Complex classes like FormModel often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use FormModel, and based on these observations, apply Extract Interface, too.

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

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

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