Test Failed
Pull Request — master (#254)
by Sergei
12:56
created

FormModel   A

Complexity

Total Complexity 38

Size/Duplication

Total Lines 306
Duplicated Lines 0 %

Test Coverage

Coverage 97.64%

Importance

Changes 14
Bugs 0 Features 0
Metric Value
eloc 94
c 14
b 0
f 0
dl 0
loc 306
rs 9.36
ccs 124
cts 127
cp 0.9764
wmc 38

16 Methods

Rating   Name   Duplication   Size   Complexity  
A getAttributeValue() 0 3 1
A generateAttributeLabel() 0 8 2
A getAttributePlaceholders() 0 3 1
A getAttributeHints() 0 3 1
A createNotFoundException() 0 3 1
A getAttributeHint() 0 3 1
A normalizePath() 0 4 1
A makePathString() 0 15 5
A isValid() 0 3 1
B readAttributeMetaValue() 0 43 8
A getFormName() 0 12 3
A getAttributePlaceholder() 0 3 1
A getAttributeLabel() 0 3 1
A getAttributeLabels() 0 3 1
B readAttributeValue() 0 43 8
A hasAttribute() 0 8 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Form;
6
7
use InvalidArgumentException;
8
use ReflectionClass;
9
use ReflectionException;
10
use Yiisoft\Hydrator\Validator\ValidatedInputTrait;
11
use Yiisoft\Strings\Inflector;
12
use Yiisoft\Strings\StringHelper;
13
14
use function array_key_exists;
15
use function array_slice;
16
use function is_array;
17
use function is_object;
18
use function str_contains;
19
use function strrchr;
20
use function substr;
21
22
/**
23
 * Form model represents an HTML form: its data, validation and presentation.
24
 */
25
abstract class FormModel implements FormModelInterface
26
{
27
    use ValidatedInputTrait;
28
29
    private const META_LABEL = 1;
30
    private const META_HINT = 2;
31
    private const META_PLACEHOLDER = 3;
32
33
    private static ?Inflector $inflector = null;
34
35
    /**
36
     * Returns the text hint for the specified attribute.
37
     *
38
     * @param string $attribute the attribute name.
39
     *
40
     * @return string the attribute hint.
41
     */
42
    public function getAttributeHint(string $attribute): string
43
    {
44
        return $this->readAttributeMetaValue(self::META_HINT, $attribute) ?? '';
45
    }
46
47
    /**
48
     * Returns the attribute hints.
49 559
     *
50
     * Attribute hints are mainly used for display purpose. For example, given an attribute `isPublic`, we can declare
51 559
     * a hint `Whether the post should be visible for not logged-in users`, which provides user-friendly description of
52
     * the attribute meaning and can be displayed to end users.
53
     *
54 1
     * Unlike label hint will not be generated, if its explicit declaration is omitted.
55
     *
56 1
     * Note, in order to inherit hints defined in the parent class, a child class needs to merge the parent hints with
57
     * child hints using functions such as `array_merge()`.
58
     *
59 405
     * @return array attribute hints (name => hint)
60
     *
61 405
     * @psalm-return array<string,string>
62
     */
63
    public function getAttributeHints(): array
64 101
    {
65
        return [];
66 101
    }
67
68
    /**
69 204
     * Returns the text label for the specified attribute.
70
     *
71 204
     * @param string $attribute The attribute name.
72
     *
73
     * @return string The attribute label.
74 5
     */
75
    public function getAttributeLabel(string $attribute): string
76 5
    {
77
        return $this->readAttributeMetaValue(self::META_LABEL, $attribute) ?? $this->generateAttributeLabel($attribute);
78
    }
79 183
80
    /**
81 183
     * Returns the attribute labels.
82
     *
83
     * Attribute labels are mainly used for display purpose. For example, given an attribute `firstName`, we can
84 122
     * declare a label `First Name` which is more user-friendly and can be displayed to end users.
85
     *
86 122
     * By default, an attribute label is generated automatically. This method allows you to
87
     * explicitly specify attribute labels.
88
     *
89 429
     * Note, in order to inherit labels defined in the parent class, a child class needs to merge the parent labels
90
     * with child labels using functions such as `array_merge()`.
91 429
     *
92
     * @return array attribute labels (name => label)
93
     *
94 438
     * {@see \Yiisoft\Form\FormModel::getAttributeLabel()}
95
     *
96 438
     * @psalm-return array<string,string>
97
     */
98
    public function getAttributeLabels(): array
99
    {
100
        return [];
101
    }
102 435
103
    /**
104 435
     * Returns the text placeholder for the specified attribute.
105 434
     *
106
     * @param string $attribute the attribute name.
107
     *
108 435
     * @return string the attribute placeholder.
109
     */
110
    public function getAttributePlaceholder(string $attribute): string
111
    {
112
        return $this->readAttributeMetaValue(self::META_PLACEHOLDER, $attribute) ?? '';
113
    }
114 432
115
    public function getAttributeValue(string $attribute): mixed
116 432
    {
117 7
        return $this->readAttributeValue($attribute);
118
    }
119
120 426
    /**
121 426
     * Returns the attribute placeholders.
122 1
     *
123
     * @return array attribute placeholder (name => placeholder)
124
     *
125 425
     * @psalm-return array<string,string>
126
     */
127
    public function getAttributePlaceholders(): array
128 504
    {
129
        return [];
130
    }
131 504
132 6
    /**
133 6
     * Returns the form name that this model class should use.
134
     *
135 501
     * The form name is mainly used by {@see \Yiisoft\Form\Helper\HtmlForm} to determine how to name the input
136
     * fields for the attributes in a model.
137
     * If the form name is "A" and an attribute name is "b", then the corresponding input name would be "A[b]".
138 19
     * If the form name is an empty string, then the input name would be "b".
139
     *
140 19
     * The purpose of the above naming schema is that for forms which contain multiple different models, the attributes
141 2
     * of each model are grouped in sub-arrays of the POST-data, and it is easier to differentiate between them.
142
     *
143
     * By default, this method returns the model class name (without the namespace part) as the form name. You may
144 17
     * override it when the model is used in different forms.
145 17
     *
146
     * @return string The form name of this model class.
147 17
     */
148 4
    public function getFormName(): string
149 14
    {
150 12
        if (str_contains(static::class, '@anonymous')) {
151
            return '';
152
        }
153 12
154
        $className = strrchr(static::class, '\\');
155
        if ($className === false) {
156
            return static::class;
157
        }
158
159 17
        return substr($className, 1);
160 16
    }
161
162
    /**
163 17
     * If there is such attribute in the set.
164
     */
165
    public function hasAttribute(string $attribute): bool
166 19
    {
167
        try {
168 19
            $this->readAttributeValue($attribute);
169 2
        } catch (InvalidAttributeException) {
170
            return false;
171
        }
172 18
        return true;
173
    }
174 18
175
    public function isValid(): bool
176 16
    {
177 16
        return (bool) $this->getValidationResult()?->isValid();
178 16
    }
179 16
180 16
    /**
181 16
     * @throws InvalidAttributeException
182 16
     */
183
    private function readAttributeValue(string $attribute): mixed
184
    {
185 18
        $path = $this->normalizePath($attribute);
186
187
        $value = $this;
188 96
        $keys = [[static::class, $this]];
189
        foreach ($path as $key) {
190 96
            $keys[] = [$key, $value];
191 96
192 96
            if (is_array($value)) {
193
                if (array_key_exists($key, $value)) {
194
                    /** @var mixed $value */
195
                    $value = $value[$key];
196 96
                    continue;
197
                }
198
                throw $this->createNotFoundException($keys);
199 1
            }
200
201 1
            if (is_object($value)) {
202
                $class = new ReflectionClass($value);
203
                try {
204
                    $property = $class->getProperty($key);
205
                } catch (ReflectionException) {
206
                    throw $this->createNotFoundException($keys);
207
                }
208
                if ($property->isStatic()) {
209
                    throw $this->createNotFoundException($keys);
210
                }
211 559
                if (PHP_VERSION_ID < 80100) {
212
                    $property->setAccessible(true);
213 559
                }
214 559
                /** @var mixed $value */
215
                $value = $property->getValue($value);
216 559
                continue;
217 554
            }
218 40
219
            array_pop($keys);
220
            throw new InvalidAttributeException(
221
                sprintf('Attribute "%s" is not a nested attribute.', $this->makePathString($keys))
222 554
            );
223
        }
224 554
225
        return $value;
226
    }
227 559
228
    private function readAttributeMetaValue(int $metaKey, string $attribute): ?string
229
    {
230
        $path = $this->normalizePath($attribute);
231
232
        $value = $this;
233 96
        $n = 0;
234
        foreach ($path as $key) {
235 96
            if ($value instanceof self) {
236 96
                $nestedAttribute = implode('.', array_slice($path, $n));
237 96
                $data = match ($metaKey) {
238 96
                    self::META_LABEL => $value->getAttributeLabels(),
239 96
                    self::META_HINT => $value->getAttributeHints(),
240
                    self::META_PLACEHOLDER => $value->getAttributePlaceholders(),
241
                    default => throw new InvalidArgumentException('Invalid meta key.'),
242
                };
243
                if (array_key_exists($nestedAttribute, $data)) {
244 18
                    return $data[$nestedAttribute];
245
                }
246 18
            }
247
248
            $class = new ReflectionClass($value);
249 18
            try {
250 18
                $property = $class->getProperty($key);
251 18
            } catch (ReflectionException) {
252 18
                return null;
253 18
            }
254 18
            if ($property->isStatic()) {
255
                return null;
256 18
            }
257
258
            if (PHP_VERSION_ID < 80100) {
259 18
                $property->setAccessible(true);
260
            }
261
            /** @var mixed $value */
262
            $value = $property->getValue($value);
263
            if (!is_object($value)) {
264
                return null;
265
            }
266
267 18
            $n++;
268
        }
269 18
270 18
        return null;
271
    }
272
273
    /**
274 2
     * Generates a user-friendly attribute label based on the give attribute name.
275
     *
276
     * This is done by replacing underscores, dashes and dots with blanks and changing the first letter of each word to
277 2
     * upper case.
278
     *
279 2
     * For example, 'department_name' or 'DepartmentName' will generate 'Department Name'.
280
     *
281
     * @param string $attribute The attribute name.
282
     *
283 2
     * @return string The attribute label.
284
     */
285
    private function generateAttributeLabel(string $attribute): string
286
    {
287 2
        if (self::$inflector === null) {
288
            self::$inflector = new Inflector();
289
        }
290 333
291
        return StringHelper::uppercaseFirstCharacterInEachWord(
292 333
            self::$inflector->toWords($attribute)
0 ignored issues
show
Bug introduced by
The method toWords() does not exist on null. ( Ignorable by Annotation )

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

292
            self::$inflector->/** @scrutinizer ignore-call */ 
293
                              toWords($attribute)

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
293
        );
294
    }
295 1
296
    /**
297 1
     * @return string[]
298
     */
299
    private function normalizePath(string $attribute): array
300
    {
301
        $attribute = str_replace(['][', '['], '.', rtrim($attribute, ']'));
302
        return StringHelper::parsePath($attribute);
303 524
    }
304
305 524
    /**
306
     * @psalm-param array<array-key, array{0:int|string, 1:mixed}> $keys
307 524
     */
308 524
    private function createNotFoundException(array $keys): InvalidArgumentException
309 524
    {
310 524
        return new InvalidAttributeException('Undefined property: "' . $this->makePathString($keys) . '".');
311
    }
312 524
313 3
    /**
314
     * @psalm-param array<array-key, array{0:int|string, 1:mixed}> $keys
315 2
     */
316 2
    private function makePathString(array $keys): string
317
    {
318 1
        $path = '';
319
        foreach ($keys as $key) {
320
            if ($path !== '') {
321 524
                if (is_object($key[1])) {
322 524
                    $path .= '::' . $key[0];
323
                } elseif (is_array($key[1])) {
324 524
                    $path .= '[' . $key[0] . ']';
325 7
                }
326 7
            } else {
327
                $path = (string) $key[0];
328 521
            }
329 1
        }
330
        return $path;
331 521
    }
332
}
333