Passed
Pull Request — master (#192)
by Alexander
05:47 queued 02:55
created

FormModel::readProperty()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 22
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 2

Importance

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