Passed
Pull Request — master (#196)
by Dmitriy
09:08 queued 06:34
created

FormModel   C

Complexity

Total Complexity 54

Size/Duplication

Total Lines 346
Duplicated Lines 0 %

Test Coverage

Coverage 98.52%

Importance

Changes 9
Bugs 0 Features 0
Metric Value
eloc 112
dl 0
loc 346
ccs 133
cts 135
cp 0.9852
rs 6.4799
c 9
b 0
f 0
wmc 54

28 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 3 1
A getAttributeValue() 0 3 1
A addErrors() 0 5 3
A collectAttributes() 0 17 4
A attributes() 0 3 1
A getAttributePlaceholders() 0 3 1
A getAttributeHints() 0 3 1
A getAttributeHint() 0 7 2
A getAttributePlaceholder() 0 7 2
A getAttributeLabel() 0 12 3
A getAttributeLabels() 0 3 1
A getFormErrors() 0 7 2
A setAttribute() 0 15 2
A getAttributeCastValue() 0 3 1
A getFormName() 0 12 3
A hasAttribute() 0 5 2
A load() 0 17 5
A writeProperty() 0 16 1
A setFormErrors() 0 3 1
A generateAttributeLabel() 0 4 1
A getRules() 0 3 1
A processValidationResult() 0 10 3
A getInflector() 0 6 2
A getNestedAttributeValue() 0 14 2
A isValidated() 0 3 1
A readProperty() 0 22 2
A getNestedAttribute() 0 20 4
A getData() 0 3 1

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\Strings\Inflector;
12
use Yiisoft\Strings\StringHelper;
13
use Yiisoft\Validator\PostValidationHookInterface;
14
use Yiisoft\Validator\Result;
15
use Yiisoft\Validator\RulesProviderInterface;
16
17
use function array_key_exists;
18
use function array_keys;
19
use function explode;
20
use function is_subclass_of;
21
use function property_exists;
22
use function str_contains;
23
use function strrchr;
24
use function substr;
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
    private array $attributes;
32
    private ?FormErrorsInterface $formErrors = null;
33
    private ?Inflector $inflector = null;
34
    /** @psalm-var array<string, string|array> */
35
    private array $rawData = [];
36
    private bool $validated = false;
37
38 752
    public function __construct()
39
    {
40 752
        $this->attributes = $this->collectAttributes();
41
    }
42
43 1
    public function attributes(): array
44
    {
45 1
        return array_keys($this->attributes);
46
    }
47
48 342
    public function getAttributeHint(string $attribute): string
49
    {
50 342
        $attributeHints = $this->getAttributeHints();
51 342
        $hint = $attributeHints[$attribute] ?? '';
52 342
        $nestedAttributeHint = $this->getNestedAttributeValue('getAttributeHint', $attribute);
53
54 342
        return $nestedAttributeHint !== '' ? $nestedAttributeHint : $hint;
55
    }
56
57
    /**
58
     * @return string[]
59
     */
60 326
    public function getAttributeHints(): array
61
    {
62 326
        return [];
63
    }
64
65 350
    public function getAttributeLabel(string $attribute): string
66
    {
67 350
        $label = $this->generateAttributeLabel($attribute);
68 350
        $labels = $this->getAttributeLabels();
69
70 350
        if (array_key_exists($attribute, $labels)) {
71 2
            $label = $labels[$attribute];
72
        }
73
74 350
        $nestedAttributeLabel = $this->getNestedAttributeValue('getAttributeLabel', $attribute);
75
76 350
        return $nestedAttributeLabel !== '' ? $nestedAttributeLabel : $label;
77
    }
78
79
    /**
80
     * @return string[]
81
     */
82 348
    public function getAttributeLabels(): array
83
    {
84 348
        return [];
85
    }
86
87 352
    public function getAttributePlaceholder(string $attribute): string
88
    {
89 352
        $attributePlaceHolders = $this->getAttributePlaceholders();
90 352
        $placeholder = $attributePlaceHolders[$attribute] ?? '';
91 352
        $nestedAttributePlaceholder = $this->getNestedAttributeValue('getAttributePlaceholder', $attribute);
92
93 352
        return $nestedAttributePlaceholder !== '' ? $nestedAttributePlaceholder : $placeholder;
94
    }
95
96
    /**
97
     * @return string[]
98
     */
99 350
    public function getAttributePlaceholders(): array
100
    {
101 350
        return [];
102
    }
103
104 656
    public function getAttributeCastValue(string $attribute): mixed
105
    {
106 656
        return $this->readProperty($attribute);
107
    }
108
109 666
    public function getAttributeValue(string $attribute): mixed
110
    {
111 666
        return $this->rawData[$attribute] ?? $this->getAttributeCastValue($attribute);
112
    }
113
114
    /**
115
     * @return FormErrorsInterface Get FormErrors object.
116
     */
117 362
    public function getFormErrors(): FormErrorsInterface
118
    {
119 362
        if ($this->formErrors === null) {
120 361
            $this->formErrors = new FormErrors();
121
        }
122
123 362
        return $this->formErrors;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->formErrors could return the type null which is incompatible with the type-hinted return Yiisoft\Form\FormErrorsInterface. Consider adding an additional type-check to rule them out.
Loading history...
124
    }
125
126
    /**
127
     * @return string Returns classname without a namespace part or empty string when class is anonymous
128
     */
129 708
    public function getFormName(): string
130
    {
131 708
        if (str_contains(static::class, '@anonymous')) {
132 7
            return '';
133
        }
134
135 702
        $className = strrchr(static::class, '\\');
136 702
        if ($className === false) {
137 1
            return static::class;
138
        }
139
140 701
        return substr($className, 1);
141
    }
142
143 689
    public function hasAttribute(string $attribute): bool
144
    {
145 689
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
146
147 688
        return $nested !== null || array_key_exists($attribute, $this->attributes);
148
    }
149
150
    /**
151
     * @param array $data
152
     * @param string|null $formName
153
     *
154
     * @return bool
155
     *
156
     * @psalm-param array<string, string|array> $data
157
     */
158 33
    public function load(array $data, ?string $formName = null): bool
159
    {
160 33
        $this->rawData = [];
161 33
        $scope = $formName ?? $this->getFormName();
162
163 33
        if ($scope === '' && !empty($data)) {
164 3
            $this->rawData = $data;
165 31
        } elseif (isset($data[$scope])) {
166
            /** @var array<string, string> */
167 30
            $this->rawData = $data[$scope];
168
        }
169
170 33
        foreach ($this->rawData as $name => $value) {
171 33
            $this->setAttribute($name, $value);
172
        }
173
174 33
        return $this->rawData !== [];
175
    }
176
177 82
    public function setAttribute(string $name, mixed $value): void
178
    {
179 82
        [$realName] = $this->getNestedAttribute($name);
180
181 82
        if (isset($this->attributes[$realName])) {
182
            /** @var mixed */
183 81
            $value = match ($this->attributes[$realName]) {
184 5
                'bool' => (bool) $value,
185 1
                'float' => (float) $value,
186 17
                'int' => (int) $value,
187 69
                'string' => (string) $value,
188 14
                default => $value,
189
            };
190
191 81
            $this->writeProperty($name, $value);
192
        }
193
    }
194
195 35
    public function processValidationResult(Result $result): void
196
    {
197 35
        foreach ($result->getErrors() as $error) {
198 33
            $attribute = (string)$error->getAttribute();
199 33
            if ($this->hasAttribute($attribute)) {
200 33
                $this->addErrors([$attribute => [$error->getMessage()]]);
201
            }
202
        }
203
204 35
        $this->validated = true;
205
    }
206
207 503
    public function getRules(): array
208
    {
209 503
        return [];
210
    }
211
212 1
    public function setFormErrors(FormErrorsInterface $formErrors): void
213
    {
214 1
        $this->formErrors = $formErrors;
215
    }
216
217
    /**
218
     * Returns the list of attribute types indexed by attribute names.
219
     *
220
     * By default, this method returns all non-static properties of the class.
221
     *
222
     * @return array list of attribute types indexed by attribute names.
223
     */
224 752
    protected function collectAttributes(): array
225
    {
226 752
        $class = new ReflectionClass($this);
227 752
        $attributes = [];
228
229 752
        foreach ($class->getProperties() as $property) {
230 747
            if ($property->isStatic()) {
231 35
                continue;
232
            }
233
234
            /** @var ReflectionNamedType|null $type */
235 747
            $type = $property->getType();
236
237 747
            $attributes[$property->getName()] = $type !== null ? $type->getName() : '';
238
        }
239
240 752
        return $attributes;
241
    }
242
243
    /**
244
     * @psalm-param  non-empty-array<string, non-empty-list<string>> $items
245
     */
246 33
    private function addErrors(array $items): void
247
    {
248 33
        foreach ($items as $attribute => $errors) {
249 33
            foreach ($errors as $error) {
250 33
                $this->getFormErrors()->addError($attribute, $error);
251
            }
252
        }
253
    }
254
255 350
    private function getInflector(): Inflector
256
    {
257 350
        if ($this->inflector === null) {
258 350
            $this->inflector = new Inflector();
259
        }
260 350
        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...
261
    }
262
263
    /**
264
     * Generates a user-friendly attribute label based on the give attribute name.
265
     *
266
     * This is done by replacing underscores, dashes and dots with blanks and changing the first letter of each word to
267
     * upper case.
268
     *
269
     * For example, 'department_name' or 'DepartmentName' will generate 'Department Name'.
270
     *
271
     * @param string $name the column name.
272
     *
273
     * @return string the attribute label.
274
     */
275 350
    private function generateAttributeLabel(string $name): string
276
    {
277 350
        return StringHelper::uppercaseFirstCharacterInEachWord(
278 350
            $this->getInflector()->toWords($name)
279
        );
280
    }
281
282 656
    private function readProperty(string $attribute): mixed
283
    {
284 656
        $class = static::class;
285
286 656
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
287
288 655
        if (!property_exists($class, $attribute)) {
289 1
            throw new InvalidArgumentException("Undefined property: \"$class::$attribute\".");
290
        }
291
292
        /** @psalm-suppress MixedMethodCall */
293 654
        $getter = static function (FormModelInterface $class, string $attribute, ?string $nested): mixed {
294 654
            return match ($nested) {
295 654
                null => $class->$attribute,
296 654
                default => $class->$attribute->getAttributeCastValue($nested),
297
            };
298
        };
299
300 654
        $getter = Closure::bind($getter, null, $this);
301
302
        /** @var Closure $getter */
303 654
        return $getter($this, $attribute, $nested);
304
    }
305
306 81
    private function writeProperty(string $attribute, mixed $value): void
307
    {
308 81
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
309
310
        /** @psalm-suppress MixedMethodCall */
311 81
        $setter = static function (FormModelInterface $class, string $attribute, mixed $value, ?string $nested): void {
312 81
            match ($nested) {
313 81
                null => $class->$attribute = $value,
314 2
                default => $class->$attribute->setAttribute($nested, $value),
315
            };
316
        };
317
318 81
        $setter = Closure::bind($setter, null, $this);
319
320
        /** @var Closure $setter */
321 81
        $setter($this, $attribute, $value, $nested);
322
    }
323
324
    /**
325
     * @return string[]
326
     *
327
     * @psalm-return array{0: string, 1: null|string}
328
     */
329 705
    private function getNestedAttribute(string $attribute): array
330
    {
331 705
        if (!str_contains($attribute, '.')) {
332 703
            return [$attribute, null];
333
        }
334
335 9
        [$attribute, $nested] = explode('.', $attribute, 2);
336
337
        /** @var string */
338 9
        $attributeNested = $this->attributes[$attribute] ?? '';
339
340 9
        if (!is_subclass_of($attributeNested, self::class)) {
341 1
            throw new InvalidArgumentException("Attribute \"$attribute\" is not a nested attribute.");
342
        }
343
344 8
        if (!property_exists($attributeNested, $nested)) {
345 1
            throw new InvalidArgumentException("Undefined property: \"$attributeNested::$nested\".");
346
        }
347
348 7
        return [$attribute, $nested];
349
    }
350
351 385
    private function getNestedAttributeValue(string $method, string $attribute): string
352
    {
353 385
        $result = '';
354
355 385
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
356
357 385
        if ($nested !== null) {
358
            /** @var FormModelInterface $attributeNestedValue */
359 3
            $attributeNestedValue = $this->getAttributeCastValue($attribute);
360
            /** @var string */
361 3
            $result = $attributeNestedValue->$method($nested);
362
        }
363
364 385
        return $result;
365
    }
366
367 6
    public function isValidated(): bool
368
    {
369 6
        return $this->validated;
370
    }
371
372
    public function getData(): mixed
373
    {
374
        return $this->attributes;
375
    }
376
}
377