Passed
Push — master ( cdc8a6...94344c )
by Alexander
07:08 queued 04:33
created

FormModel::load()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 26
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 7.0178

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 13
c 1
b 0
f 0
nc 8
nop 2
dl 0
loc 26
ccs 13
cts 14
cp 0.9286
crap 7.0178
rs 8.8333
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
    private array $rawData = [];
35
    private bool $validated = false;
36
37 537
    public function __construct()
38
    {
39 537
        $this->attributes = $this->collectAttributes();
40
    }
41
42 1
    public function attributes(): array
43
    {
44 1
        return array_keys($this->attributes);
45
    }
46
47 385
    public function getAttributeHint(string $attribute): string
48
    {
49 385
        $attributeHints = $this->getAttributeHints();
50 385
        $hint = $attributeHints[$attribute] ?? '';
51 385
        $nestedAttributeHint = $this->getNestedAttributeValue('getAttributeHint', $attribute);
52
53 385
        return $nestedAttributeHint !== '' ? $nestedAttributeHint : $hint;
54
    }
55
56
    /**
57
     * @return string[]
58
     */
59 94
    public function getAttributeHints(): array
60
    {
61 94
        return [];
62
    }
63
64 196
    public function getAttributeLabel(string $attribute): string
65
    {
66 196
        $label = $this->generateAttributeLabel($attribute);
67 196
        $labels = $this->getAttributeLabels();
68
69 196
        if (array_key_exists($attribute, $labels)) {
70 174
            $label = $labels[$attribute];
71
        }
72
73 196
        $nestedAttributeLabel = $this->getNestedAttributeValue('getAttributeLabel', $attribute);
74
75 196
        return $nestedAttributeLabel !== '' ? $nestedAttributeLabel : $label;
76
    }
77
78
    /**
79
     * @return string[]
80
     */
81 3
    public function getAttributeLabels(): array
82
    {
83 3
        return [];
84
    }
85
86 170
    public function getAttributePlaceholder(string $attribute): string
87
    {
88 170
        $attributePlaceHolders = $this->getAttributePlaceholders();
89 170
        $placeholder = $attributePlaceHolders[$attribute] ?? '';
90 170
        $nestedAttributePlaceholder = $this->getNestedAttributeValue('getAttributePlaceholder', $attribute);
91
92 170
        return $nestedAttributePlaceholder !== '' ? $nestedAttributePlaceholder : $placeholder;
93
    }
94
95
    /**
96
     * @return string[]
97
     */
98 116
    public function getAttributePlaceholders(): array
99
    {
100 116
        return [];
101
    }
102
103 428
    public function getAttributeCastValue(string $attribute): mixed
104
    {
105 428
        return $this->readProperty($attribute);
106
    }
107
108 434
    public function getAttributeValue(string $attribute): mixed
109
    {
110 434
        return $this->rawData[$attribute] ?? $this->getAttributeCastValue($attribute);
111
    }
112
113
    /**
114
     * @return FormErrorsInterface Get FormErrors object.
115
     */
116 415
    public function getFormErrors(): FormErrorsInterface
117
    {
118 415
        if ($this->formErrors === null) {
119 414
            $this->formErrors = new FormErrors();
120
        }
121
122 415
        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...
123
    }
124
125
    /**
126
     * @return string Returns classname without a namespace part or empty string when class is anonymous
127
     */
128 411
    public function getFormName(): string
129
    {
130 411
        if (str_contains(static::class, '@anonymous')) {
131 7
            return '';
132
        }
133
134 405
        $className = strrchr(static::class, '\\');
135 405
        if ($className === false) {
136 1
            return static::class;
137
        }
138
139 404
        return substr($className, 1);
140
    }
141
142 491
    public function hasAttribute(string $attribute): bool
143
    {
144 491
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
145
146 490
        return $nested !== null || array_key_exists($attribute, $this->attributes);
147
    }
148
149 18
    public function load(array|object|null $data, ?string $formName = null): bool
150
    {
151 18
        if (!is_array($data)) {
0 ignored issues
show
introduced by
The condition is_array($data) is always true.
Loading history...
152 2
            return false;
153
        }
154
155 16
        $this->rawData = [];
156 16
        $scope = $formName ?? $this->getFormName();
157
158 16
        if ($scope === '' && !empty($data)) {
159 3
            $this->rawData = $data;
160 14
        } elseif (isset($data[$scope])) {
161 12
            if (!is_array($data[$scope])) {
162
                return false;
163
            }
164 12
            $this->rawData = $data[$scope];
165
        }
166
167
        /**
168
         * @var mixed $value
169
         */
170 16
        foreach ($this->rawData as $name => $value) {
171 15
            $this->setAttribute((string) $name, $value);
172
        }
173
174 16
        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 90
    public function processValidationResult(Result $result): void
196
    {
197 90
        foreach ($result->getErrorMessagesIndexedByAttribute() as $attribute => $errors) {
198 90
            if ($this->hasAttribute($attribute)) {
199 90
                $this->addErrors([$attribute => $errors]);
200
            }
201
        }
202
203 90
        $this->validated = true;
204
    }
205
206 1
    public function getRules(): array
207
    {
208 1
        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 537
    protected function collectAttributes(): array
224
    {
225 537
        $class = new ReflectionClass($this);
226 537
        $attributes = [];
227
228 537
        foreach ($class->getProperties() as $property) {
229 532
            if ($property->isStatic()) {
230 39
                continue;
231
            }
232
233
            /** @var ReflectionNamedType|null $type */
234 532
            $type = $property->getType();
235
236 532
            $attributes[$property->getName()] = $type !== null ? $type->getName() : '';
237
        }
238
239 537
        return $attributes;
240
    }
241
242
    /**
243
     * @psalm-param  non-empty-array<string, non-empty-list<string>> $items
244
     */
245 90
    private function addErrors(array $items): void
246
    {
247 90
        foreach ($items as $attribute => $errors) {
248 90
            foreach ($errors as $error) {
249 90
                $this->getFormErrors()->addError($attribute, $error);
250
            }
251
        }
252
    }
253
254 196
    private function getInflector(): Inflector
255
    {
256 196
        if ($this->inflector === null) {
257 196
            $this->inflector = new Inflector();
258
        }
259 196
        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 196
    private function generateAttributeLabel(string $name): string
275
    {
276 196
        return StringHelper::uppercaseFirstCharacterInEachWord(
277 196
            $this->getInflector()->toWords($name)
278
        );
279
    }
280
281 428
    private function readProperty(string $attribute): mixed
282
    {
283 428
        $class = static::class;
284
285 428
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
286
287 427
        if (!property_exists($class, $attribute)) {
288 1
            throw new InvalidArgumentException("Undefined property: \"$class::$attribute\".");
289
        }
290
291
        /** @psalm-suppress MixedMethodCall */
292 426
        $getter = static function (FormModelInterface $class, string $attribute, ?string $nested): mixed {
293 426
            return match ($nested) {
294 426
                null => $class->$attribute,
295 426
                default => $class->$attribute->getAttributeCastValue($nested),
296
            };
297
        };
298
299 426
        $getter = Closure::bind($getter, null, $this);
300
301
        /** @var Closure $getter */
302 426
        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 507
    private function getNestedAttribute(string $attribute): array
329
    {
330 507
        if (!str_contains($attribute, '.')) {
331 505
            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 420
    private function getNestedAttributeValue(string $method, string $attribute): string
351
    {
352 420
        $result = '';
353
354 420
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
355
356 420
        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 420
        return $result;
364
    }
365
366 174
    public function isValidated(): bool
367
    {
368 174
        return $this->validated;
369
    }
370
}
371