Passed
Push — master ( 1739d7...c28568 )
by Sergei
03:10
created

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

412
            self::$inflector->/** @scrutinizer ignore-call */ 
413
                              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...
413 25
        );
414
    }
415
416
    /**
417
     * @return string[]
418
     */
419 530
    private function normalizePath(string $attribute): array
420
    {
421 530
        $attribute = str_replace(['][', '['], '.', rtrim($attribute, ']'));
422 530
        return StringHelper::parsePath($attribute);
423
    }
424
425
    /**
426
     * @psalm-param array<array-key, array{0:int|string, 1:mixed}> $keys
427
     */
428 8
    private function createNotFoundException(array $keys): InvalidArgumentException
429
    {
430 8
        return new InvalidAttributeException('Undefined property: "' . $this->makePathString($keys) . '".');
431
    }
432
433
    /**
434
     * @psalm-param array<array-key, array{0:int|string, 1:mixed}> $keys
435
     */
436 9
    private function makePathString(array $keys): string
437
    {
438 9
        $path = '';
439 9
        foreach ($keys as $key) {
440 9
            if ($path !== '') {
441 9
                if (is_object($key[1])) {
442 9
                    $path .= '::' . $key[0];
443 1
                } elseif (is_array($key[1])) {
444 9
                    $path .= '[' . $key[0] . ']';
445
                }
446
            } else {
447 9
                $path = (string) $key[0];
448
            }
449
        }
450 9
        return $path;
451
    }
452
}
453