Passed
Push — required-aria-attribute ( 34ec5a...aa07a9 )
by Dmitriy
02:31
created

FormModel::writeProperty()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 19
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4.3244

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 4
eloc 12
nc 3
nop 2
dl 0
loc 19
ccs 8
cts 11
cp 0.7272
crap 4.3244
rs 9.8666
c 2
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\Form\HtmlOptions\HtmlOptionsProvider;
11
use Yiisoft\Strings\Inflector;
12
use Yiisoft\Strings\StringHelper;
13
use Yiisoft\Validator\Rule\Required;
14
use Yiisoft\Validator\ValidatorInterface;
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 array $attributes;
30
    private array $attributesLabels;
31
    private array $attributesErrors = [];
32
    private ?Inflector $inflector = null;
33
    private bool $validated = false;
34
35 253
    public function __construct()
36
    {
37 253
        $this->attributes = $this->collectAttributes();
38 252
        $this->attributesLabels = $this->attributeLabels();
39 252
    }
40
41 50
    public function isAttributeRequired(string $attribute): bool
42
    {
43 50
        $validators = $this->rules()[$attribute] ?? [];
44
45 50
        foreach ($validators as $validator) {
46 28
            if ($validator instanceof Required) {
47 15
                return true;
48
            }
49 16
            if ($validator instanceof HtmlOptionsProvider && (bool)($validator->getHtmlOptions()['required'] ?? false)) {
50 4
                return true;
51
            }
52
        }
53
54 34
        return false;
55
    }
56
57 229
    public function getAttributeValue(string $attribute)
58
    {
59 229
        return $this->readProperty($attribute);
60
    }
61
62 12
    public function attributeLabels(): array
63
    {
64 12
        return [];
65
    }
66
67 69
    public function attributeHint(string $attribute): string
68
    {
69 69
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
70 69
        if ($nested !== null) {
71 1
            return $this->readProperty($attribute)->attributeHint($nested);
72
        }
73
74 69
        $hints = $this->attributeHints();
75
76 69
        return $hints[$attribute] ?? '';
77
    }
78
79 6
    public function attributeHints(): array
80
    {
81 6
        return [];
82
    }
83
84 136
    public function attributeLabel(string $attribute): string
85
    {
86 136
        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 241
    public function formName(): string
93
    {
94 241
        if (strpos(static::class, '@anonymous') !== false) {
95 4
            return '';
96
        }
97
98 238
        $className = strrchr(static::class, '\\');
99 238
        if ($className === false) {
100 1
            return static::class;
101
        }
102
103 237
        return substr($className, 1);
104
    }
105
106 1
    public function hasAttribute(string $attribute): bool
107
    {
108 1
        return array_key_exists($attribute, $this->attributes);
109
    }
110
111 1
    public function error(string $attribute): array
112
    {
113 1
        return $this->attributesErrors[$attribute] ?? [];
114
    }
115
116 2
    public function errors(): array
117
    {
118 2
        return $this->attributesErrors;
119
    }
120
121 4
    public function errorSummary(bool $showAllErrors): array
122
    {
123 4
        $lines = [];
124 4
        $errors = $showAllErrors ? $this->errors() : [$this->firstErrors()];
125
126 4
        foreach ($errors as $error) {
127 4
            $lines = array_merge($lines, $error);
128
        }
129
130 4
        return $lines;
131
    }
132
133 70
    public function firstError(string $attribute): string
134
    {
135 70
        if (empty($this->attributesErrors[$attribute])) {
136 58
            return '';
137
        }
138
139 15
        return reset($this->attributesErrors[$attribute]);
140
    }
141
142 3
    public function firstErrors(): array
143
    {
144 3
        if (empty($this->attributesErrors)) {
145 2
            return [];
146
        }
147
148 1
        $errors = [];
149
150 1
        foreach ($this->attributesErrors as $name => $es) {
151 1
            if (!empty($es)) {
152 1
                $errors[$name] = reset($es);
153
            }
154
        }
155
156 1
        return $errors;
157
    }
158
159 76
    public function hasErrors(?string $attribute = null): bool
160
    {
161 76
        return $attribute === null ? !empty($this->attributesErrors) : isset($this->attributesErrors[$attribute]);
162
    }
163
164
    /**
165
     * @param array $data
166
     * @param string|null $formName
167
     *
168
     * @return bool
169
     */
170 14
    public function load(array $data, ?string $formName = null): bool
171
    {
172 14
        $scope = $formName ?? $this->formName();
173
174
        /**
175
         * @psalm-var array<string,mixed>
176
         */
177 14
        $values = [];
178
179 14
        if ($scope === '' && !empty($data)) {
180 2
            $values = $data;
181 13
        } elseif (isset($data[$scope])) {
182 12
            $values = $data[$scope];
183
        }
184
185 14
        foreach ($values as $name => $value) {
186 14
            $this->setAttribute($name, $value);
187
        }
188
189 14
        return $values !== [];
190
    }
191
192 14
    public function setAttribute(string $name, $value): void
193
    {
194 14
        [$realName] = $this->getNestedAttribute($name);
195 14
        if (isset($this->attributes[$realName])) {
196 13
            switch ($this->attributes[$realName]) {
197 13
                case 'bool':
198 3
                    $this->writeProperty($name, (bool) $value);
199 3
                    break;
200 13
                case 'float':
201 1
                    $this->writeProperty($name, (float) $value);
202 1
                    break;
203 13
                case 'int':
204 2
                    $this->writeProperty($name, (int) $value);
205 2
                    break;
206 13
                case 'string':
207 13
                    $this->writeProperty($name, (string) $value);
208 13
                    break;
209
                default:
210 1
                    $this->writeProperty($name, $value);
211 1
                    break;
212
            }
213
        }
214 14
    }
215
216 22
    public function validate(ValidatorInterface $validator): bool
217
    {
218 22
        $this->clearErrors();
219
220 22
        $rules = $this->rules();
221
222 22
        if (!empty($rules)) {
223 22
            $results = $validator->validate($this, $rules);
224
225 22
            foreach ($results as $attribute => $result) {
226 22
                if ($result->isValid() === false) {
227 20
                    $this->addErrors([$attribute => $result->getErrors()]);
228
                }
229
            }
230
        }
231
232 22
        $this->validated = true;
233
234 22
        return !$this->hasErrors();
235
    }
236
237 2
    public function addError(string $attribute, string $error): void
238
    {
239 2
        $this->attributesErrors[$attribute][] = $error;
240 2
    }
241
242
    public function rules(): array
243
    {
244
        return [];
245
    }
246
247
    /**
248
     * @param string[][] $items
249
     */
250 20
    private function addErrors(array $items): void
251
    {
252 20
        foreach ($items as $attribute => $errors) {
253 20
            foreach ($errors as $error) {
254 20
                $this->attributesErrors[$attribute][] = $error;
255
            }
256
        }
257 20
    }
258
259
    /**
260
     * Returns the list of attribute types indexed by attribute names.
261
     *
262
     * By default, this method returns all non-static properties of the class.
263
     *
264
     * @throws \ReflectionException
265
     *
266
     * @return array list of attribute types indexed by attribute names.
267
     */
268 253
    private function collectAttributes(): array
269
    {
270 253
        $class = new ReflectionClass($this);
271 253
        $attributes = [];
272
273 253
        foreach ($class->getProperties() as $property) {
274 249
            if ($property->isStatic()) {
275 16
                continue;
276
            }
277
278 249
            $type = $property->getType();
279 249
            if ($type === null) {
280 1
                throw new InvalidArgumentException(sprintf(
281 1
                    'You must specify the type hint for "%s" property in "%s" class.',
282 1
                    $property->getName(),
283 1
                    $property->getDeclaringClass()->getName(),
284
                ));
285
            }
286
287 248
            $attributes[$property->getName()] = $type->getName();
0 ignored issues
show
Bug introduced by
The method getName() does not exist on ReflectionType. It seems like you code against a sub-type of ReflectionType such as ReflectionNamedType. ( Ignorable by Annotation )

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

287
            /** @scrutinizer ignore-call */ 
288
            $attributes[$property->getName()] = $type->getName();
Loading history...
288
        }
289
290 252
        return $attributes;
291
    }
292
293 22
    private function clearErrors(?string $attribute = null): void
294
    {
295 22
        if ($attribute === null) {
296 22
            $this->attributesErrors = [];
297
        } else {
298
            unset($this->attributesErrors[$attribute]);
299
        }
300
301 22
        $this->validated = false;
302 22
    }
303
304 135
    private function getInflector(): Inflector
305
    {
306 135
        if ($this->inflector === null) {
307 135
            $this->inflector = new Inflector();
308
        }
309 135
        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...
310
    }
311
312 136
    private function getAttributeLabel(string $attribute): string
313
    {
314 136
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
315
316 136
        return $nested !== null
317 1
            ? $this->readProperty($attribute)->attributeLabel($nested)
318 136
            : $this->generateAttributeLabel($attribute);
319
    }
320
321
    /**
322
     * Generates a user friendly attribute label based on the give attribute name.
323
     *
324
     * This is done by replacing underscores, dashes and dots with blanks and changing the first letter of each word to
325
     * upper case.
326
     *
327
     * For example, 'department_name' or 'DepartmentName' will generate 'Department Name'.
328
     *
329
     * @param string $name the column name.
330
     *
331
     * @return string the attribute label.
332
     */
333 135
    private function generateAttributeLabel(string $name): string
334
    {
335 135
        return StringHelper::uppercaseFirstCharacterInEachWord(
336 135
            $this->getInflector()->toWords($name)
337
        );
338
    }
339
340 231
    private function readProperty(string $attribute)
341
    {
342 231
        $class = static::class;
343
344 231
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
345
346 231
        if (!property_exists($class, $attribute)) {
347 2
            throw new InvalidArgumentException("Undefined property: \"$class::$attribute\".");
348
        }
349
350 230
        if ($this->isPublicAttribute($attribute)) {
351
            return $nested === null ? $this->$attribute : $this->$attribute->getAttributeValue($nested);
352
        }
353
354 230
        $getter = fn (FormModel $class, $attribute) => $nested === null
355 230
            ? $class->$attribute
356 230
            : $class->$attribute->getAttributeValue($nested);
357 230
        $getter = Closure::bind($getter, null, $this);
358
359
        /**
360
         * @psalm-var Closure $getter
361
         */
362 230
        return $getter($this, $attribute);
363
    }
364
365 13
    private function writeProperty(string $attribute, $value): void
366
    {
367 13
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
368 13
        if ($this->isPublicAttribute($attribute)) {
369
            if ($nested === null) {
370
                $this->$attribute = $value;
371
            } else {
372
                $this->$attribute->setAttribute($attribute, $value);
373
            }
374
        } else {
375 13
            $setter = fn (FormModel $class, $attribute, $value) => $nested === null
376 13
                ? $class->$attribute = $value
377 13
                : $class->$attribute->setAttribute($nested, $value);
378 13
            $setter = Closure::bind($setter, null, $this);
379
380
            /**
381
             * @psalm-var Closure $setter
382
             */
383 13
            $setter($this, $attribute, $value);
384
        }
385 13
    }
386
387 232
    private function isPublicAttribute(string $attribute): bool
388
    {
389 232
        return array_key_exists($attribute, get_object_vars($this));
390
    }
391
392 244
    private function getNestedAttribute(string $attribute): array
393
    {
394 244
        if (strpos($attribute, '.') === false) {
395 244
            return [$attribute, null];
396
        }
397
398 4
        [$attribute, $nested] = explode('.', $attribute, 2);
399
400 4
        if (!is_subclass_of($this->attributes[$attribute], self::class)) {
401
            throw new InvalidArgumentException('Nested attribute can only be of ' . self::class . ' type.');
402
        }
403
404 4
        return [$attribute, $nested];
405
    }
406
407 48
    public function isValidated(): bool
408
    {
409 48
        return $this->validated;
410
    }
411
}
412