Passed
Pull Request — master (#162)
by Wilmer
02:57
created

FormModel::attributes()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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