Test Failed
Push — html-pr-52 ( 326cc2...dad1cc )
by Sergei
02:10 queued 10s
created

FormModel::error()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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