Passed
Pull Request — html-like-rules (#68)
by Dmitriy
02:39 queued 41s
created

FormModel::isAttributeRequired()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 5

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 7
c 1
b 0
f 0
nc 4
nop 1
dl 0
loc 14
ccs 8
cts 8
cp 1
crap 5
rs 9.6111
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 Yiisoft\Form\HtmlOptions\HtmlOptionsProvider;
11
use Yiisoft\Strings\Inflector;
12
use Yiisoft\Strings\StringHelper;
13
use Yiisoft\Validator\Rule\Required;
14
use Yiisoft\Validator\ValidatorFactoryInterface;
15
use function array_key_exists;
16
use function array_merge;
17
use function explode;
18
use function get_object_vars;
19
use function is_subclass_of;
20
use function reset;
21
use function sprintf;
22
use function strpos;
23
24
/**
25
 * Form model represents an HTML form: its data, validation and presentation.
26
 */
27
abstract class FormModel implements FormModelInterface
28
{
29
    private ValidatorFactoryInterface $validatorFactory;
30
31
    private array $attributes;
32
    private array $attributesLabels;
33
    private array $attributesErrors = [];
34
    private ?Inflector $inflector = null;
35
    private bool $validated = false;
36
37 253
    public function __construct(ValidatorFactoryInterface $validatorFactory)
38
    {
39 253
        $this->validatorFactory = $validatorFactory;
40
41 253
        $this->attributes = $this->collectAttributes();
42 252
        $this->attributesLabels = $this->attributeLabels();
43 252
    }
44
45 50
    public function isAttributeRequired(string $attribute): bool
46
    {
47 50
        $validators = $this->rules()[$attribute] ?? [];
48
49 50
        foreach ($validators as $validator) {
50 28
            if ($validator instanceof Required) {
51 15
                return true;
52
            }
53 16
            if ($validator instanceof HtmlOptionsProvider && (bool)($validator->getHtmlOptions()['required'] ?? false)) {
54 4
                return true;
55
            }
56
        }
57
58 34
        return false;
59
    }
60
61 229
    public function getAttributeValue(string $attribute)
62
    {
63 229
        return $this->readProperty($attribute);
64
    }
65
66 12
    public function attributeLabels(): array
67
    {
68 12
        return [];
69
    }
70
71 69
    public function attributeHint(string $attribute): string
72
    {
73 69
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
74 69
        if ($nested !== null) {
75 1
            return $this->readProperty($attribute)->attributeHint($nested);
76
        }
77
78 69
        $hints = $this->attributeHints();
79
80 69
        return $hints[$attribute] ?? '';
81
    }
82
83 6
    public function attributeHints(): array
84
    {
85 6
        return [];
86
    }
87
88 136
    public function attributeLabel(string $attribute): string
89
    {
90 136
        return $this->attributesLabels[$attribute] ?? $this->getAttributeLabel($attribute);
91
    }
92
93
    /**
94
     * @return string Returns classname without a namespace part or empty string when class is anonymous
95
     */
96 241
    public function formName(): string
97
    {
98 241
        if (strpos(static::class, '@anonymous') !== false) {
99 4
            return '';
100
        }
101
102 238
        $className = strrchr(static::class, '\\');
103 238
        if ($className === false) {
104 1
            return static::class;
105
        }
106
107 237
        return substr($className, 1);
108
    }
109
110 1
    public function hasAttribute(string $attribute): bool
111
    {
112 1
        return array_key_exists($attribute, $this->attributes);
113
    }
114
115 1
    public function error(string $attribute): array
116
    {
117 1
        return $this->attributesErrors[$attribute] ?? [];
118
    }
119
120 2
    public function errors(): array
121
    {
122 2
        return $this->attributesErrors;
123
    }
124
125 4
    public function errorSummary(bool $showAllErrors): array
126
    {
127 4
        $lines = [];
128 4
        $errors = $showAllErrors ? $this->errors() : [$this->firstErrors()];
129
130 4
        foreach ($errors as $error) {
131 4
            $lines = array_merge($lines, $error);
132
        }
133
134 4
        return $lines;
135
    }
136
137 70
    public function firstError(string $attribute): string
138
    {
139 70
        if (empty($this->attributesErrors[$attribute])) {
140 58
            return '';
141
        }
142
143 15
        return reset($this->attributesErrors[$attribute]);
144
    }
145
146 3
    public function firstErrors(): array
147
    {
148 3
        if (empty($this->attributesErrors)) {
149 2
            return [];
150
        }
151
152 1
        $errors = [];
153
154 1
        foreach ($this->attributesErrors as $name => $es) {
155 1
            if (!empty($es)) {
156 1
                $errors[$name] = reset($es);
157
            }
158
        }
159
160 1
        return $errors;
161
    }
162
163 76
    public function hasErrors(?string $attribute = null): bool
164
    {
165 76
        return $attribute === null ? !empty($this->attributesErrors) : isset($this->attributesErrors[$attribute]);
166
    }
167
168
    /**
169
     * @param array $data
170
     * @param string|null $formName
171
     *
172
     * @return bool
173
     */
174 14
    public function load(array $data, ?string $formName = null): bool
175
    {
176 14
        $scope = $formName ?? $this->formName();
177
178
        /**
179
         * @psalm-var array<string,mixed>
180
         */
181 14
        $values = [];
182
183 14
        if ($scope === '' && !empty($data)) {
184 2
            $values = $data;
185 13
        } elseif (isset($data[$scope])) {
186 12
            $values = $data[$scope];
187
        }
188
189 14
        foreach ($values as $name => $value) {
190 14
            $this->setAttribute($name, $value);
191
        }
192
193 14
        return $values !== [];
194
    }
195
196 14
    public function setAttribute(string $name, $value): void
197
    {
198 14
        [$realName] = $this->getNestedAttribute($name);
199 14
        if (isset($this->attributes[$realName])) {
200 13
            switch ($this->attributes[$realName]) {
201 13
                case 'bool':
202 3
                    $this->writeProperty($name, (bool) $value);
203 3
                    break;
204 13
                case 'float':
205 1
                    $this->writeProperty($name, (float) $value);
206 1
                    break;
207 13
                case 'int':
208 2
                    $this->writeProperty($name, (int) $value);
209 2
                    break;
210 13
                case 'string':
211 13
                    $this->writeProperty($name, (string) $value);
212 13
                    break;
213
                default:
214 1
                    $this->writeProperty($name, $value);
215 1
                    break;
216
            }
217
        }
218 14
    }
219
220 22
    public function validate(): bool
221
    {
222 22
        $this->clearErrors();
223
224 22
        $rules = $this->rules();
225
226 22
        if (!empty($rules)) {
227 22
            $results = $this->validatorFactory
228 22
                ->create($rules)
229 22
                ->validate($this);
230
231 22
            foreach ($results as $attribute => $result) {
232 22
                if ($result->isValid() === false) {
233 20
                    $this->addErrors([$attribute => $result->getErrors()]);
234
                }
235
            }
236
        }
237
238 22
        $this->validated = true;
239
240 22
        return !$this->hasErrors();
241
    }
242
243 2
    public function addError(string $attribute, string $error): void
244
    {
245 2
        $this->attributesErrors[$attribute][] = $error;
246 2
    }
247
248
    public function rules(): array
249
    {
250
        return [];
251
    }
252
253
    /**
254
     * @param string[][] $items
255
     */
256 20
    private function addErrors(array $items): void
257
    {
258 20
        foreach ($items as $attribute => $errors) {
259 20
            foreach ($errors as $error) {
260 20
                $this->attributesErrors[$attribute][] = $error;
261
            }
262
        }
263 20
    }
264
265
    /**
266
     * Returns the list of attribute types indexed by attribute names.
267
     *
268
     * By default, this method returns all non-static properties of the class.
269
     *
270
     * @throws \ReflectionException
271
     *
272
     * @return array list of attribute types indexed by attribute names.
273
     */
274 253
    private function collectAttributes(): array
275
    {
276 253
        $class = new ReflectionClass($this);
277 253
        $attributes = [];
278
279 253
        foreach ($class->getProperties() as $property) {
280 249
            if ($property->isStatic()) {
281 16
                continue;
282
            }
283
284 249
            $type = $property->getType();
285 249
            if ($type === null) {
286 1
                throw new InvalidArgumentException(sprintf(
287 1
                    'You must specify the type hint for "%s" property in "%s" class.',
288 1
                    $property->getName(),
289 1
                    $property->getDeclaringClass()->getName(),
290
                ));
291
            }
292
293 248
            $attributes[$property->getName()] = $type->getName();
0 ignored issues
show
Bug introduced by
The method getName() does not exist on ReflectionType. It seems like you code against a sub-type of ReflectionType such as ReflectionNamedType. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

293
            /** @scrutinizer ignore-call */ 
294
            $attributes[$property->getName()] = $type->getName();
Loading history...
294
        }
295
296 252
        return $attributes;
297
    }
298
299 22
    private function clearErrors(?string $attribute = null): void
300
    {
301 22
        if ($attribute === null) {
302 22
            $this->attributesErrors = [];
303
        } else {
304
            unset($this->attributesErrors[$attribute]);
305
        }
306
307 22
        $this->validated = false;
308 22
    }
309
310 135
    private function getInflector(): Inflector
311
    {
312 135
        if ($this->inflector === null) {
313 135
            $this->inflector = new Inflector();
314
        }
315 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...
316
    }
317
318 136
    private function getAttributeLabel(string $attribute): string
319
    {
320 136
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
321
322 136
        return $nested !== null
323 1
            ? $this->readProperty($attribute)->attributeLabel($nested)
324 136
            : $this->generateAttributeLabel($attribute);
325
    }
326
327
    /**
328
     * Generates a user friendly attribute label based on the give attribute name.
329
     *
330
     * This is done by replacing underscores, dashes and dots with blanks and changing the first letter of each word to
331
     * upper case.
332
     *
333
     * For example, 'department_name' or 'DepartmentName' will generate 'Department Name'.
334
     *
335
     * @param string $name the column name.
336
     *
337
     * @return string the attribute label.
338
     */
339 135
    private function generateAttributeLabel(string $name): string
340
    {
341 135
        return StringHelper::uppercaseFirstCharacterInEachWord(
342 135
            $this->getInflector()->toWords($name)
343
        );
344
    }
345
346 231
    private function readProperty(string $attribute)
347
    {
348 231
        $class = static::class;
349
350 231
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
351
352 231
        if (!property_exists($class, $attribute)) {
353 2
            throw new InvalidArgumentException("Undefined property: \"$class::$attribute\".");
354
        }
355
356 230
        if ($this->isPublicAttribute($attribute)) {
357
            return $nested === null ? $this->$attribute : $this->$attribute->getAttributeValue($nested);
358
        }
359
360 230
        $getter = fn (FormModel $class, $attribute) => $nested === null
361 230
            ? $class->$attribute
362 230
            : $class->$attribute->getAttributeValue($nested);
363 230
        $getter = Closure::bind($getter, null, $this);
364
365
        /**
366
         * @psalm-var Closure $getter
367
         */
368 230
        return $getter($this, $attribute);
369
    }
370
371 13
    private function writeProperty(string $attribute, $value): void
372
    {
373 13
        [$attribute, $nested] = $this->getNestedAttribute($attribute);
374 13
        if ($this->isPublicAttribute($attribute)) {
375
            if ($nested === null) {
376
                $this->$attribute = $value;
377
            } else {
378
                $this->$attribute->setAttribute($attribute, $value);
379
            }
380
        } else {
381 13
            $setter = fn (FormModel $class, $attribute, $value) => $nested === null
382 13
                ? $class->$attribute = $value
383 13
                : $class->$attribute->setAttribute($nested, $value);
384 13
            $setter = Closure::bind($setter, null, $this);
385
386
            /**
387
             * @psalm-var Closure $setter
388
             */
389 13
            $setter($this, $attribute, $value);
390
        }
391 13
    }
392
393 232
    private function isPublicAttribute(string $attribute): bool
394
    {
395 232
        return array_key_exists($attribute, get_object_vars($this));
396
    }
397
398 244
    private function getNestedAttribute(string $attribute): array
399
    {
400 244
        if (strpos($attribute, '.') === false) {
401 244
            return [$attribute, null];
402
        }
403
404 4
        [$attribute, $nested] = explode('.', $attribute, 2);
405
406 4
        if (!is_subclass_of($this->attributes[$attribute], self::class)) {
407
            throw new InvalidArgumentException('Nested attribute can only be of ' . self::class . ' type.');
408
        }
409
410 4
        return [$attribute, $nested];
411
    }
412
413 48
    public function isValidated(): bool
414
    {
415 48
        return $this->validated;
416
    }
417
}
418