Passed
Pull Request — master (#192)
by Alexander
05:43 queued 02:47
created

FormModel   C

Complexity

Total Complexity 53

Size/Duplication

Total Lines 340
Duplicated Lines 0 %

Test Coverage

Coverage 98.48%

Importance

Changes 9
Bugs 0 Features 0
Metric Value
eloc 110
c 9
b 0
f 0
dl 0
loc 340
ccs 130
cts 132
cp 0.9848
rs 6.96
wmc 53

27 Methods

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