Passed
Pull Request — master (#186)
by
unknown
02:08
created

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