Passed
Pull Request — master (#155)
by Wilmer
11:04
created

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