Passed
Push — fix-html ( 588533 )
by Alexander
12:56
created

FormModel::load()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 9
nc 6
nop 2
dl 0
loc 17
rs 9.6111
c 1
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 Yiisoft\Strings\StringHelper;
11
use Yiisoft\Strings\Inflector;
12
use Yiisoft\Validator\Rule\Required;
13
use Yiisoft\Validator\ValidatorFactoryInterface;
14
15
use function array_key_exists;
16
use function array_merge;
17
use function explode;
18
use function get_object_vars;
19
use function is_subclass_of;
20
use function reset;
21
use function sprintf;
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
28
{
29
    private ValidatorFactoryInterface $validatorFactory;
30
31
    private array $attributes;
32
    private array $attributesLabels;
33
    private array $attributesErrors = [];
34
    private ?Inflector $inflector = null;
35
36
    public function __construct(ValidatorFactoryInterface $validatorFactory)
37
    {
38
        $this->validatorFactory = $validatorFactory;
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
        }
53
54
        return false;
55
    }
56
57
    public function getAttributeValue(string $attribute)
58
    {
59
        return $this->readProperty($attribute);
60
    }
61
62
    public function attributeLabels(): array
63
    {
64
        return [];
65
    }
66
67
    public function attributeHint(string $attribute): string
68
    {
69
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
70
        if ($nested !== null) {
71
            return $this->readProperty($attribute)->attributeHint($nested);
72
        }
73
74
        $hints = $this->attributeHints();
75
76
        return $hints[$attribute] ?? '';
77
    }
78
79
    public function attributeHints(): array
80
    {
81
        return [];
82
    }
83
84
    public function attributeLabel(string $attribute): string
85
    {
86
        return $this->attributesLabels[$attribute] ?? $this->getAttributeLabel($attribute);
87
    }
88
89
    /**
90
     * @return string Returns classname without a namespace part or empty string when class is anonymous
91
     */
92
    public function formName(): string
93
    {
94
        return strpos(static::class, '@anonymous') !== false
95
            ? ''
96
            : substr(strrchr(static::class, "\\"), 1);
97
    }
98
99
    public function hasAttribute(string $attribute): bool
100
    {
101
        return array_key_exists($attribute, $this->attributes);
102
    }
103
104
    public function error(string $attribute): array
105
    {
106
        return $this->attributesErrors[$attribute] ?? [];
107
    }
108
109
    public function errors(): array
110
    {
111
        return $this->attributesErrors;
112
    }
113
114
    public function errorSummary(bool $showAllErrors): array
115
    {
116
        $lines = [];
117
        $errors = $showAllErrors ? $this->errors() : [$this->firstErrors()];
118
119
        foreach ($errors as $error) {
120
            $lines = array_merge($lines, $error);
121
        }
122
123
        return $lines;
124
    }
125
126
    public function firstError(string $attribute): string
127
    {
128
        return isset($this->attributesErrors[$attribute]) ? reset($this->attributesErrors[$attribute]) : '';
129
    }
130
131
    public function firstErrors(): array
132
    {
133
        if (empty($this->attributesErrors)) {
134
            return [];
135
        }
136
137
        $errors = [];
138
139
        foreach ($this->attributesErrors as $name => $es) {
140
            if (!empty($es)) {
141
                $errors[$name] = reset($es);
142
            }
143
        }
144
145
        return $errors;
146
    }
147
148
    public function hasErrors(?string $attribute = null): bool
149
    {
150
        return $attribute === null ? !empty($this->attributesErrors) : isset($this->attributesErrors[$attribute]);
151
    }
152
153
    public function load(array $data, $formName = null): bool
154
    {
155
        $scope = $formName ?? $this->formName();
156
157
        $values = [];
158
159
        if ($scope === '' && !empty($data)) {
160
            $values = $data;
161
        } elseif (isset($data[$scope])) {
162
            $values = $data[$scope];
163
        }
164
165
        foreach ($values as $name => $value) {
166
            $this->setAttribute($name, $value);
167
        }
168
169
        return $values !== [];
170
    }
171
172
    public function setAttribute(string $name, $value): void
173
    {
174
        [$realName] = $this->getNestedAttribute($name);
175
        if (isset($this->attributes[$realName])) {
176
            switch ($this->attributes[$realName]) {
177
                case 'bool':
178
                    $this->writeProperty($name, (bool) $value);
179
                    break;
180
                case 'float':
181
                    $this->writeProperty($name, (float) $value);
182
                    break;
183
                case 'int':
184
                    $this->writeProperty($name, (int) $value);
185
                    break;
186
                case 'string':
187
                    $this->writeProperty($name, (string) $value);
188
                    break;
189
                default:
190
                    $this->writeProperty($name, $value);
191
                    break;
192
            }
193
        }
194
    }
195
196
    public function validate(): bool
197
    {
198
        $this->clearErrors();
199
200
        $rules = $this->rules();
201
202
        if (!empty($rules)) {
203
            $results = $this->validatorFactory
204
                ->create($rules)
205
                ->validate($this);
206
207
            foreach ($results as $attribute => $result) {
208
                if ($result->isValid() === false) {
209
                    $this->addErrors([$attribute => $result->getErrors()]);
210
                }
211
            }
212
        }
213
214
        return !$this->hasErrors();
215
    }
216
217
    public function addError(string $attribute, string $error): void
218
    {
219
        $this->attributesErrors[$attribute][] = $error;
220
    }
221
222
    /**
223
     * Returns the validation rules for attributes.
224
     *
225
     * Validation rules are used by {@see \Yiisoft\Validator\Validator} to check if attribute values are valid.
226
     * Child classes may override this method to declare different validation rules.
227
     *
228
     * Each rule is an array with the following structure:
229
     *
230
     * ```php
231
     * public function rules(): array
232
     * {
233
     *     return [
234
     *         'login' => $this->loginRules()
235
     *     ];
236
     * }
237
     *
238
     * private function loginRules(): array
239
     * {
240
     *   return [
241
     *       new \Yiisoft\Validator\Rule\Required(),
242
     *       (new \Yiisoft\Validator\Rule\HasLength())
243
     *       ->min(4)
244
     *       ->max(40)
245
     *       ->tooShortMessage('Is too short.')
246
     *       ->tooLongMessage('Is too long.'),
247
     *       new \Yiisoft\Validator\Rule\Email()
248
     *   ];
249
     * }
250
     * ```
251
     *
252
     * @return array validation rules
253
     */
254
    protected function rules(): array
255
    {
256
        return [];
257
    }
258
259
    /**
260
     * @param string[][] $items
261
     */
262
    private function addErrors(array $items): void
263
    {
264
        foreach ($items as $attribute => $errors) {
265
            foreach ($errors as $error) {
266
                $this->attributesErrors[$attribute][] = $error;
267
            }
268
        }
269
    }
270
271
    /**
272
     * Returns the list of attribute types indexed by attribute names.
273
     *
274
     * By default, this method returns all non-static properties of the class.
275
     *
276
     * @throws \ReflectionException
277
     *
278
     * @return array list of attribute types indexed by attribute names.
279
     */
280
    private function collectAttributes(): array
281
    {
282
        $class = new ReflectionClass($this);
283
        $attributes = [];
284
285
        foreach ($class->getProperties() as $property) {
286
            if ($property->isStatic()) {
287
                continue;
288
            }
289
290
            $type = $property->getType();
0 ignored issues
show
Bug introduced by
The method getType() does not exist on ReflectionProperty. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

290
            /** @scrutinizer ignore-call */ 
291
            $type = $property->getType();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
291
            if ($type === null) {
292
                throw new InvalidArgumentException(sprintf(
293
                    'You must specify the type hint for "%s" property in "%s" class.',
294
                    $property->getName(),
295
                    $property->getDeclaringClass()->getName(),
296
                ));
297
            }
298
299
            $attributes[$property->getName()] = $type->getName();
300
        }
301
302
        return $attributes;
303
    }
304
305
    private function clearErrors(?string $attribute = null): void
306
    {
307
        if ($attribute === null) {
308
            $this->attributesErrors = [];
309
        } else {
310
            unset($this->attributesErrors[$attribute]);
311
        }
312
    }
313
314
    private function getInflector(): Inflector
315
    {
316
        if ($this->inflector === null) {
317
            $this->inflector = new Inflector();
318
        }
319
        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...
320
    }
321
322
    private function getAttributeLabel(string $attribute): string
323
    {
324
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
325
326
        return $nested !== null
327
            ? $this->readProperty($attribute)->attributeLabel($nested)
328
            : $this->generateAttributeLabel($attribute);
329
    }
330
331
    /**
332
     * Generates a user friendly attribute label based on the give attribute name.
333
     *
334
     * This is done by replacing underscores, dashes and dots with blanks and changing the first letter of each word to
335
     * upper case.
336
     *
337
     * For example, 'department_name' or 'DepartmentName' will generate 'Department Name'.
338
     *
339
     * @param string $name the column name.
340
     *
341
     * @return string the attribute label.
342
     */
343
    private function generateAttributeLabel(string $name): string
344
    {
345
        return StringHelper::uppercaseFirstCharacterInEachWord(
346
            $this->getInflector()->toWords($name, true)
0 ignored issues
show
Unused Code introduced by
The call to Yiisoft\Strings\Inflector::toWords() has too many arguments starting with true. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

346
            $this->getInflector()->/** @scrutinizer ignore-call */ toWords($name, true)

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
347
        );
348
    }
349
350
    private function readProperty(string $attribute)
351
    {
352
        $class = get_class($this);
353
354
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
355
356
        if (!property_exists($class, $attribute)) {
357
            throw new InvalidArgumentException("Undefined property: \"$class::$attribute\".");
358
        }
359
360
        if ($this->isPublicAttribute($attribute)) {
361
            return $nested === null ? $this->$attribute : $this->$attribute->getAttributeValue($nested);
362
        }
363
364
        $getter = fn (FormModel $class, $attribute) => $nested === null
365
            ? $class->$attribute
366
            : $class->$attribute->getAttributeValue($nested);
367
        $getter = Closure::bind($getter, null, $this);
368
369
        return $getter($this, $attribute);
370
    }
371
372
    private function writeProperty(string $attribute, $value): void
373
    {
374
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
375
        if ($this->isPublicAttribute($attribute)) {
376
            if ($nested === null) {
377
                $this->$attribute = $value;
378
            } else {
379
                $this->$attribute->setAttribute($attribute, $value);
380
            }
381
        } else {
382
            $setter = fn(FormModel $class, $attribute, $value) => $nested === null
383
                ? $class->$attribute = $value
384
                : $class->$attribute->setAttribute($nested, $value);
385
            $setter = Closure::bind($setter, null, $this);
386
387
            $setter($this, $attribute, $value);
388
        }
389
    }
390
391
    private function isPublicAttribute(string $attribute): bool
392
    {
393
        return array_key_exists($attribute, get_object_vars($this));
394
    }
395
396
    private function getNestedAttribute(string $attribute): array
397
    {
398
        if (strpos($attribute, '.') === false) {
399
            return [$attribute, null];
400
        }
401
402
        [$attribute, $nested] = explode('.', $attribute, 2);
403
404
        if (!is_subclass_of($this->attributes[$attribute], self::class)) {
405
            throw new InvalidArgumentException('Nested attribute can only be of ' . self::class . ' type.');
406
        }
407
408
        return [$attribute, $nested];
409
    }
410
}
411