Passed
Push — extract-attributes ( ad85ab...afa8b0 )
by Dmitriy
05:59 queued 03:00
created

FormModel   F

Complexity

Total Complexity 77

Size/Duplication

Total Lines 406
Duplicated Lines 0 %

Test Coverage

Coverage 90.72%

Importance

Changes 13
Bugs 0 Features 0
Metric Value
eloc 166
c 13
b 0
f 0
dl 0
loc 406
ccs 176
cts 194
cp 0.9072
rs 2.24
wmc 77

29 Methods

Rating   Name   Duplication   Size   Complexity  
A getAttributeValue() 0 3 1
A isAttributeRequired() 0 14 5
A getAttributeLabels() 0 3 1
A __construct() 0 3 1
A writeProperty() 0 19 4
A isPublicAttribute() 0 3 1
A generateAttributeLabel() 0 4 1
A getErrorSummary() 0 10 3
A getRules() 0 3 1
A processValidationResult() 0 11 4
A getInflector() 0 6 2
A getAttributeHints() 0 3 1
A setAttribute() 0 21 6
A hasErrors() 0 13 4
A collectAttributes() 0 33 4
A getAttributeHint() 0 15 3
A getErrors() 0 11 3
A getError() 0 3 1
A clearErrors() 0 3 1
A getFormName() 0 12 3
A getFirstError() 0 8 2
A isValidated() 0 3 1
A getAttribute() 0 6 2
A readProperty() 0 23 5
A getAttributeLabel() 0 18 4
A getNestedAttribute() 0 19 4
A getFirstErrors() 0 11 3
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 ReflectionNamedType;
11
use Yiisoft\Form\HtmlOptions\HtmlOptionsProvider;
12
use Yiisoft\Strings\Inflector;
13
use Yiisoft\Strings\StringHelper;
14
use Yiisoft\Validator\PostValidationHookInterface;
15
use Yiisoft\Validator\ResultSet;
16
use Yiisoft\Validator\Rule\Required;
17
use Yiisoft\Validator\RulesProviderInterface;
18
use function array_key_exists;
19
use function array_merge;
20
use function explode;
21
use function get_object_vars;
22
use function reset;
23
use function sprintf;
24
use function strpos;
25
26
/**
27
 * Form model represents an HTML form: its data, validation and presentation.
28
 */
29
abstract class FormModel implements FormModelInterface, PostValidationHookInterface, RulesProviderInterface
30
{
31
    /**
32
     * @var FormAttribute[]
33
     */
34
    private array $attributes = [];
35
    private ?Inflector $inflector = null;
36
    private bool $validated = false;
37
38 249
    public function __construct()
39
    {
40 249
        $this->attributes = $this->collectAttributes();
41 248
    }
42
43 50
    public function isAttributeRequired(string $attribute): bool
44
    {
45 50
        $validators = $this->getRules()[$attribute] ?? [];
46
47 50
        foreach ($validators as $validator) {
48 28
            if ($validator instanceof Required) {
49 15
                return true;
50
            }
51 16
            if ($validator instanceof HtmlOptionsProvider && (bool)($validator->getHtmlOptions()['required'] ?? false)) {
52 4
                return true;
53
            }
54
        }
55
56 34
        return false;
57
    }
58
59 227
    public function getAttributeValue(string $attribute)
60
    {
61 227
        return $this->readProperty($attribute);
62
    }
63
64 13
    public function getAttributeLabels(): array
65
    {
66 13
        return [];
67
    }
68
69 244
    public function getAttributeHint(string $attribute): string
70
    {
71 244
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
72 244
        if ($nested !== null) {
73 1
            $property = $this->readProperty($attribute);
74 1
            if ($property instanceof self) {
75 1
                return $property->getAttributeHint($nested);
76
            }
77
78
            return $property;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $property could return the type Yiisoft\Form\FormAttribute which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
79
        }
80
81 244
        $hints = $this->getAttributeHints();
82
83 244
        return $hints[$attribute] ?? '';
84
    }
85
86 13
    public function getAttributeHints(): array
87
    {
88 13
        return [];
89
    }
90
91 236
    public function getAttributeLabel(string $attribute): string
92
    {
93 236
        if ($this->hasAttribute($attribute)) {
94 135
            return $this->getAttribute($attribute)->getLabel();
95
        }
96
97 236
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
98
99 236
        if ($nested !== null) {
100 1
            $property = $this->readProperty($attribute);
101 1
            if ($property instanceof self) {
102 1
                return $property->getAttributeLabel($nested);
103
            }
104
105
            return $property;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $property could return the type Yiisoft\Form\FormAttribute which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
106
        }
107
108 236
        return $this->generateAttributeLabel($attribute);
109
    }
110
111
    /**
112
     * @return string Returns classname without a namespace part or empty string when class is anonymous
113
     */
114 239
    public function getFormName(): string
115
    {
116 239
        if (strpos(static::class, '@anonymous') !== false) {
117 4
            return '';
118
        }
119
120 236
        $className = strrchr(static::class, '\\');
121 236
        if ($className === false) {
122 1
            return static::class;
123
        }
124
125 235
        return substr($className, 1);
126
    }
127
128 157
    public function getAttribute(string $attribute): FormAttribute
129
    {
130 157
        if (!$this->hasAttribute($attribute)) {
131
            throw new InvalidArgumentException("Attribute \"{$attribute}\" does not exist.");
132
        }
133 157
        return $this->attributes[$attribute];
134
    }
135
136 241
    public function hasAttribute(string $attribute): bool
137
    {
138 241
        return array_key_exists($attribute, $this->attributes);
139
    }
140
141 1
    public function getError(string $attribute): array
142
    {
143 1
        return $this->getAttribute($attribute)->getErrors();
144
    }
145
146 2
    public function getErrors(): array
147
    {
148 2
        $result = [];
149 2
        foreach ($this->attributes as $attributeName => $attribute) {
150 2
            $errors = $attribute->getErrors();
151 2
            if ($errors !== []) {
152 2
                $result[$attributeName] = $errors;
153
            }
154
        }
155
156 2
        return $result;
157
    }
158
159 4
    public function getErrorSummary(bool $showAllErrors): array
160
    {
161 4
        $lines = [];
162 4
        $errors = $showAllErrors ? $this->getErrors() : [$this->getFirstErrors()];
163
164 4
        foreach ($errors as $error) {
165 4
            $lines = array_merge($lines, $error);
166
        }
167
168 4
        return $lines;
169
    }
170
171 68
    public function getFirstError(string $attribute): string
172
    {
173 68
        $errors = $this->getAttribute($attribute)->getErrors();
174 68
        if (empty($errors)) {
175 58
            return '';
176
        }
177
178 13
        return reset($errors);
179
    }
180
181 3
    public function getFirstErrors(): array
182
    {
183 3
        $result = [];
184 3
        foreach ($this->attributes as $attributeName => $attribute) {
185 3
            $errors = $attribute->getErrors();
186 3
            if ($errors !== []) {
187 1
                $result[$attributeName] = reset($errors);
188
            }
189
        }
190
191 3
        return $result;
192
    }
193
194 12
    public function hasErrors(?string $attribute = null): bool
195
    {
196 12
        if ($attribute !== null) {
197 12
            return $this->getAttribute($attribute)->getErrors() !== [];
198
        }
199
200
        /** @noinspection SuspiciousLoopInspection */
201
        foreach ($this->attributes as $attribute) {
202
            if ($attribute->getErrors() !== []) {
203
                return true;
204
            }
205
        }
206
        return false;
207
    }
208
209
    /**
210
     * @param array $data
211
     * @param string|null $formName
212
     *
213
     * @return bool
214
     */
215 14
    public function load(array $data, ?string $formName = null): bool
216
    {
217 14
        $scope = $formName ?? $this->getFormName();
218
219
        /**
220
         * @psalm-var array<string,mixed>
221
         */
222 14
        $values = [];
223
224 14
        if ($scope === '' && !empty($data)) {
225 2
            $values = $data;
226 13
        } elseif (isset($data[$scope])) {
227 12
            $values = $data[$scope];
228
        }
229
230 14
        foreach ($values as $name => $value) {
231 14
            $this->setAttribute($name, $value);
232
        }
233
234 14
        return $values !== [];
235
    }
236
237 14
    public function setAttribute(string $name, $value): void
238
    {
239 14
        [$realName] = $this->getNestedAttribute($name);
240 14
        if ($this->hasAttribute($realName)) {
241 13
            $attribute = $this->getAttribute($realName);
242 13
            switch ($attribute->getType()) {
243 13
                case 'bool':
244 3
                    $this->writeProperty($name, (bool) $value);
245 3
                    break;
246 13
                case 'float':
247 1
                    $this->writeProperty($name, (float) $value);
248 1
                    break;
249 13
                case 'int':
250 2
                    $this->writeProperty($name, (int) $value);
251 2
                    break;
252 13
                case 'string':
253 13
                    $this->writeProperty($name, (string) $value);
254 13
                    break;
255
                default:
256 1
                    $this->writeProperty($name, $value);
257 1
                    break;
258
            }
259
        }
260 14
    }
261
262 22
    public function processValidationResult(ResultSet $resultSet): void
263
    {
264 22
        $this->clearErrors();
265 22
        $this->validated = false;
266
267 22
        foreach ($resultSet as $attribute => $result) {
268 22
            if ($result->isValid() === false && ($errors = $result->getErrors()) !== []) {
269 20
                $this->getAttribute($attribute)->setErrors($errors);
270
            }
271
        }
272 22
        $this->validated = true;
273 22
    }
274
275
    public function getRules(): array
276
    {
277
        return [];
278
    }
279
280
    /**
281
     * Returns the list of attribute types indexed by attribute names.
282
     *
283
     * By default, this method returns all non-static properties of the class.
284
     *
285
     * @throws \ReflectionException
286
     *
287
     * @return array list of attribute types indexed by attribute names.
288
     */
289 249
    private function collectAttributes(): array
290
    {
291 249
        $class = new ReflectionClass($this);
292 249
        $attributes = [];
293
294 249
        $attributeLabels = $this->getAttributeLabels();
295 249
        $attributeHints = $this->getAttributeHints();
296
297 249
        foreach ($class->getProperties() as $property) {
298 245
            if ($property->isStatic()) {
299 14
                continue;
300
            }
301
302
            /** @var ReflectionNamedType $propertyType */
303 245
            $propertyType = $property->getType();
304 245
            $propertyName = $property->getName();
305 245
            if ($propertyType === null) {
306 1
                throw new InvalidArgumentException(sprintf(
307 1
                    'You must specify the type hint for "%s" property in "%s" class.',
308 1
                    $propertyName,
309 1
                    $property->getDeclaringClass()->getName(),
310
                ));
311
            }
312
313 244
            $attributes[$propertyName] = new FormAttribute(
314 244
                $propertyName,
315 244
                $propertyType->getName(),
316 244
                $attributeHints[$propertyName] ?? $this->getAttributeHint($propertyName),
317 244
                $attributeLabels[$propertyName] ?? $this->getAttributeLabel($propertyName),
318
            );
319
        }
320
321 248
        return $attributes;
322
    }
323
324 22
    private function clearErrors(): void
325
    {
326 22
        array_walk($this->attributes, static fn (FormAttribute $attribute) => $attribute->setErrors([]));
327 22
    }
328
329 236
    private function getInflector(): Inflector
330
    {
331 236
        if ($this->inflector === null) {
332 236
            $this->inflector = new Inflector();
333
        }
334 236
        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...
335
    }
336
337
    /**
338
     * Generates a user friendly attribute label based on the give attribute name.
339
     *
340
     * This is done by replacing underscores, dashes and dots with blanks and changing the first letter of each word to
341
     * upper case.
342
     *
343
     * For example, 'department_name' or 'DepartmentName' will generate 'Department Name'.
344
     *
345
     * @param string $name the column name.
346
     *
347
     * @return string the attribute label.
348
     */
349 236
    private function generateAttributeLabel(string $name): string
350
    {
351 236
        return StringHelper::uppercaseFirstCharacterInEachWord(
352 236
            $this->getInflector()->toWords($name)
353
        );
354
    }
355
356
    /**
357
     * @return string|FormAttribute|FormModel
358
     */
359 229
    private function readProperty(string $attribute)
360
    {
361 229
        $class = static::class;
362
363 229
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
364
365 229
        if (!property_exists($class, $attribute)) {
366 2
            throw new InvalidArgumentException("Undefined property: \"$class::$attribute\".");
367
        }
368
369 228
        if ($this->isPublicAttribute($attribute)) {
370
            return $nested === null ? $this->$attribute : $this->$attribute->getAttributeValue($nested);
371
        }
372
373 228
        $getter = static fn (FormModel $class, $attribute) => $nested === null
374 228
            ? $class->$attribute
375 228
            : $class->$attribute->getAttributeValue($nested);
376 228
        $getter = Closure::bind($getter, null, $this);
377
378
        /**
379
         * @psalm-var Closure<string|FormAttribute|FormModel> $getter
380
         */
381 228
        return $getter($this, $attribute);
382
    }
383
384 13
    private function writeProperty(string $attribute, $value): void
385
    {
386 13
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
387 13
        if ($this->isPublicAttribute($attribute)) {
388
            if ($nested === null) {
389
                $this->$attribute = $value;
390
            } else {
391
                $this->$attribute->setAttribute($attribute, $value);
392
            }
393
        } else {
394 13
            $setter = fn (FormModel $class, $attribute, $value) => $nested === null
395 13
                ? $class->$attribute = $value
396 13
                : $class->$attribute->setAttribute($nested, $value);
397 13
            $setter = Closure::bind($setter, null, $this);
398
399
            /**
400
             * @psalm-var Closure $setter
401
             */
402 13
            $setter($this, $attribute, $value);
403
        }
404 13
    }
405
406 230
    private function isPublicAttribute(string $attribute): bool
407
    {
408 230
        return array_key_exists($attribute, get_object_vars($this));
409
    }
410
411 244
    private function getNestedAttribute(string $attribute): array
412
    {
413 244
        if (strpos($attribute, '.') === false) {
414 244
            return [$attribute, null];
415
        }
416
417 4
        [$attribute, $nested] = explode('.', $attribute, 2);
418
419 4
        $nestedAttribute = $this->getAttribute($attribute);
420 4
        if (!($nestedAttribute instanceof FormAttribute)) {
0 ignored issues
show
introduced by
$nestedAttribute is always a sub-type of Yiisoft\Form\FormAttribute.
Loading history...
421
            throw new InvalidArgumentException(sprintf(
422
                'Nested attribute can only be of %s or %s types, type %s got.',
423
                FormAttribute::class,
424
                self::class,
425
                is_object($nestedAttribute) ? get_class($nestedAttribute) : gettype($nestedAttribute)
426
            ));
427
        }
428
429 4
        return [$attribute, $nested];
430
    }
431
432 64
    public function isValidated(): bool
433
    {
434 64
        return $this->validated;
435
    }
436
}
437