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