Passed
Pull Request — master (#284)
by Sergei
05:53 queued 02:39
created

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

306
            self::$inflector->/** @scrutinizer ignore-call */ 
307
                              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...
307 28
        );
308
    }
309
310
    /**
311
     * @return string[]
312
     */
313 505
    private function normalizePath(string $attribute): array
314
    {
315 505
        $attribute = str_replace(['][', '['], '.', rtrim($attribute, ']'));
316 505
        return StringHelper::parsePath($attribute);
317
    }
318
319
    /**
320
     * @psalm-param array<array-key, array{0:int|string, 1:mixed}> $keys
321
     */
322 11
    private function makePropertyPathString(array $keys): string
323
    {
324 11
        $path = '';
325 11
        foreach ($keys as $key) {
326 11
            if ($path !== '') {
327 11
                if (is_object($key[1])) {
328 11
                    $path .= '::' . $key[0];
329 3
                } elseif (is_array($key[1])) {
330 11
                    $path .= '[' . $key[0] . ']';
331
                }
332
            } else {
333 11
                $path = (string) $key[0];
334
            }
335
        }
336 11
        return $path;
337
    }
338
}
339