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

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