Passed
Pull Request — master (#191)
by Wilmer
02:51
created

FormModel   B

Complexity

Total Complexity 51

Size/Duplication

Total Lines 339
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 9
Bugs 0 Features 0
Metric Value
eloc 110
dl 0
loc 339
ccs 132
cts 132
cp 1
rs 7.92
c 9
b 0
f 0
wmc 51

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 load() 0 17 5
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 getAttributeCastValue() 0 3 1
A getFormErrors() 0 5 1
A writeProperty() 0 16 1
A setFormErrors() 0 3 1
A generateAttributeLabel() 0 4 1
A getRules() 0 3 1
A processValidationResult() 0 9 3
A getInflector() 0 5 1
A setAttribute() 0 15 2
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

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