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

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

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
366
                    /** @var array<array-key,string> $nested */
367 5
                    $class->$attribute->setAttribute(implode('.', $nested), $value);
368 3
                } elseif (is_array($value)) {
369 3
                    $class->$attribute->load($value, '');
370
                } else {
371 6
                    throw new RuntimeException('$value must be array for using as nested attribute');
372
                }
373
            } else {
374 87
                $class->$attribute = $value;
375
            }
376
        };
377
378 87
        $closure = Closure::bind($setter, null, $this);
379
        /** @var Closure $closure */
380 87
        $closure($this, $attribute, $value, $nested);
381
    }
382
383
    /**
384
     * @return string[]
385
     *
386
     * @psalm-return array{0: string, 1: null|string[]}
387
     */
388 752
    private function getNestedAttribute(string $attribute, string ...$nested): array
389
    {
390 752
        if (!$nested) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $nested of type array<integer,string> is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
391 752
            if (!str_contains($attribute, '.')) {
392 750
                return [$attribute, null];
393
            }
394
395 16
            $nested = explode('.', $attribute);
396 16
            $attribute = array_shift($nested);
397
        }
398
399
        /** @var string */
400 16
        $attributeNested = $this->attributes[$attribute] ?? '';
401
402 16
        if (!is_subclass_of($attributeNested, self::class)) {
403 1
            throw new InvalidArgumentException("Attribute \"$attribute\" is not a nested attribute.");
404
        }
405
406 15
        if (!property_exists($attributeNested, $nested[0])) {
407 2
            throw new InvalidArgumentException("Undefined property: \"$attributeNested::$nested[0]\".");
408
        }
409
410 14
        return [$attribute, $nested];
411
    }
412
413 430
    private function getNestedAttributeValue(string $method, string $attribute, string ...$nested): mixed
414
    {
415 430
        $result = '';
416
417 430
        [$attribute, $nested] = $this->getNestedAttribute($attribute, ...$nested);
418
419 430
        if ($nested !== null) {
420
            /** @var FormModelInterface $attributeNestedValue */
421 13
            $attributeNestedValue = $this->getAttributeCastValue($attribute);
422
            /** @var string */
423 13
            $result = $attributeNestedValue->$method(...$nested);
424
        }
425
426 430
        return $result;
427
    }
428
429 6
    public function isValidated(): bool
430
    {
431 6
        return $this->validated;
432
    }
433
}
434