Passed
Pull Request — master (#229)
by Wilmer
03:18
created

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