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