Passed
Push — master ( c7a7a9...09327a )
by Alexander
02:25
created

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