Passed
Pull Request — master (#265)
by
unknown
02:54
created

FormModel::normalizePath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

420
            self::$inflector->/** @scrutinizer ignore-call */ 
421
                              toWords($attribute)

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
421 25
        );
422
    }
423
424
    /**
425
     * @return string[]
426
     */
427 530
    private function normalizePath(string $attribute): array
428
    {
429 530
        $attribute = str_replace(['][', '['], '.', rtrim($attribute, ']'));
430 530
        return StringHelper::parsePath($attribute);
431
    }
432
433
    /**
434
     * @psalm-param array<array-key, array{0:int|string, 1:mixed}> $keys
435
     */
436 8
    private function createNotFoundException(array $keys): InvalidArgumentException
437
    {
438 8
        return new InvalidAttributeException('Undefined property: "' . $this->makePathString($keys) . '".');
439
    }
440
441
    /**
442
     * @psalm-param array<array-key, array{0:int|string, 1:mixed}> $keys
443
     */
444 9
    private function makePathString(array $keys): string
445
    {
446 9
        $path = '';
447 9
        foreach ($keys as $key) {
448 9
            if ($path !== '') {
449 9
                if (is_object($key[1])) {
450 9
                    $path .= '::' . $key[0];
451 1
                } elseif (is_array($key[1])) {
452 9
                    $path .= '[' . $key[0] . ']';
453
                }
454
            } else {
455 9
                $path = (string) $key[0];
456
            }
457
        }
458 9
        return $path;
459
    }
460
}
461