Test Failed
Push — extract-attributes ( 91fc6d...a98993 )
by Dmitriy
02:31
created

FormModel::getAttribute()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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