Passed
Pull Request — master (#176)
by Wilmer
11:38
created

FormModel::addErrors()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3

Importance

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