Passed
Push — master ( ee88fd...07cb91 )
by Alexander
03:42 queued 01:09
created

FormModel::attributes()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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