Passed
Push — master ( df2813...c7aa5d )
by Rustam
02:36
created

FormModel   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 355
Duplicated Lines 0 %

Test Coverage

Coverage 99.3%

Importance

Changes 10
Bugs 0 Features 0
Metric Value
eloc 122
dl 0
loc 355
ccs 142
cts 143
cp 0.993
rs 4.5599
c 10
b 0
f 0
wmc 58

28 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 3 1
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 getAttributeCastValue() 0 3 1
A getFormName() 0 12 3
A hasAttribute() 0 5 2
A writeProperty() 0 16 1
A setFormErrors() 0 3 1
A generateAttributeLabel() 0 6 1
A getRules() 0 5 2
A processValidationResult() 0 9 3
A getInflector() 0 6 2
A addErrors() 0 7 3
A setAttribute() 0 20 3
A collectAttributes() 0 17 4
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
B load() 0 26 7

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