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

FormModel::isEmpty()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 3

Importance

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