Passed
Pull Request — master (#186)
by
unknown
05:35 queued 03:15
created

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