Passed
Push — rename-getters ( eab17b )
by Dmitriy
02:49
created

FormModel::firstError()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

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