Passed
Push — master ( 9f2aae...499dd3 )
by Sergei
06:09 queued 03:07
created

FormModel::readProperty()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 22
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 2

Importance

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