Passed
Pull Request — master (#222)
by
unknown
02:25
created

FormModel::getAttributeValue()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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