Passed
Push — master ( 7bf385...40cf15 )
by Wilmer
57s queued 13s
created

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