Passed
Push — extract-errors ( ce0b18 )
by Dmitriy
03:11
created

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