Passed
Pull Request — master (#274)
by Sergei
03:21
created

FormModel::makePropertyPathString()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 15
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 5

Importance

Changes 0
Metric Value
cc 5
eloc 10
c 0
b 0
f 0
nc 5
nop 1
dl 0
loc 15
ccs 10
cts 10
cp 1
crap 5
rs 9.6111
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 407
    public function getAttributeHint(string $attribute): string
43
    {
44 407
        return $this->readAttributeMetaValue(self::META_HINT, $attribute) ?? '';
45
    }
46
47
    /**
48
     * Returns the attribute hints.
49
     *
50
     * Attribute hints are mainly used for display purpose. For example, given an attribute `isPublic`, we can declare
51
     * 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
     * Unlike label hint will not be generated, if its explicit declaration is omitted.
55
     *
56
     * 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
     * @return array attribute hints (name => hint)
60
     *
61
     * @psalm-return array<string,string>
62
     */
63 101
    public function getAttributeHints(): array
64
    {
65 101
        return [];
66
    }
67
68
    /**
69
     * Returns the text label for the specified attribute.
70
     *
71
     * @param string $attribute The attribute name.
72
     *
73
     * @return string The attribute label.
74
     */
75 206
    public function getAttributeLabel(string $attribute): string
76
    {
77 206
        return $this->readAttributeMetaValue(self::META_LABEL, $attribute) ?? $this->generateAttributeLabel($attribute);
78
    }
79
80
    /**
81
     * Returns the attribute labels.
82
     *
83
     * Attribute labels are mainly used for display purpose. For example, given an attribute `firstName`, we can
84
     * declare a label `First Name` which is more user-friendly and can be displayed to end users.
85
     *
86
     * By default, an attribute label is generated automatically. This method allows you to
87
     * explicitly specify attribute labels.
88
     *
89
     * 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
     *
92
     * @return array attribute labels (name => label)
93
     *
94
     * {@see \Yiisoft\Form\FormModel::getAttributeLabel()}
95
     *
96
     * @psalm-return array<string,string>
97
     */
98 5
    public function getAttributeLabels(): array
99
    {
100 5
        return [];
101
    }
102
103
    /**
104
     * Returns the text placeholder for the specified attribute.
105
     *
106
     * @param string $attribute the attribute name.
107
     *
108
     * @return string the attribute placeholder.
109
     */
110 183
    public function getAttributePlaceholder(string $attribute): string
111
    {
112 183
        return $this->readAttributeMetaValue(self::META_PLACEHOLDER, $attribute) ?? '';
113
    }
114
115 383
    public function getAttributeValue(string $attribute): mixed
116
    {
117 383
        return $this->readAttributeValue($attribute);
118
    }
119
120
    /**
121
     * Returns the attribute placeholders.
122
     *
123
     * @return array attribute placeholder (name => placeholder)
124
     *
125
     * @psalm-return array<string,string>
126
     */
127 122
    public function getAttributePlaceholders(): array
128
    {
129 122
        return [];
130
    }
131
132
    /**
133
     * Returns the form name that this model class should use.
134
     *
135
     * 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
     * If the form name is an empty string, then the input name would be "b".
139
     *
140
     * The purpose of the above naming schema is that for forms which contain multiple different models, the attributes
141
     * 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
     * override it when the model is used in different forms.
145
     *
146
     * @return string The form name of this model class.
147
     */
148 432
    public function getFormName(): string
149
    {
150 432
        if (str_contains(static::class, '@anonymous')) {
151 7
            return '';
152
        }
153
154 426
        $className = strrchr(static::class, '\\');
155 426
        if ($className === false) {
156 1
            return static::class;
157
        }
158
159 425
        return substr($className, 1);
160
    }
161
162
    /**
163
     * If there is such attribute in the set.
164
     */
165 472
    public function hasAttribute(string $attribute): bool
166
    {
167
        try {
168 472
            $this->readAttributeValue($attribute);
169 4
        } catch (UndefinedPropertyException) {
170 4
            return false;
171
        }
172 470
        return true;
173
    }
174
175
    public function isValid(): bool
176
    {
177
        return (bool) $this->getValidationResult()?->isValid();
178
    }
179
180
    /**
181
     * @throws UndefinedPropertyException If the property is not in the set.
182
     */
183 494
    private function readAttributeValue(string $attribute): mixed
184
    {
185 494
        $path = $this->normalizePath($attribute);
186
187 494
        $value = $this;
188 494
        $keys = [[static::class, $this]];
189 494
        foreach ($path as $key) {
190 494
            $keys[] = [$key, $value];
191
192 494
            if (is_array($value)) {
193 3
                if (array_key_exists($key, $value)) {
194 2
                    $value = $value[$key];
195 2
                    continue;
196
                }
197 1
                throw UndefinedPropertyException::forUndefinedProperty($this->makePropertyPathString($keys));
198
            }
199
200 494
            if (is_object($value)) {
201 494
                $class = new ReflectionClass($value);
202
                try {
203 494
                    $property = $class->getProperty($key);
204 5
                } catch (ReflectionException) {
205 5
                    throw UndefinedPropertyException::forUndefinedProperty($this->makePropertyPathString($keys));
206
                }
207 492
                if ($property->isStatic()) {
208 1
                    throw UndefinedPropertyException::forUndefinedProperty($this->makePropertyPathString($keys));
209
                }
210 492
                if (PHP_VERSION_ID < 80100) {
211 492
                    $property->setAccessible(true);
212
                }
213 492
                $value = $property->getValue($value);
214 492
                continue;
215
            }
216
217 1
            array_pop($keys);
218 1
            throw UndefinedPropertyException::forNotNestedProperty($this->makePropertyPathString($keys));
219
        }
220
221 489
        return $value;
222
    }
223
224 442
    private function readAttributeMetaValue(int $metaKey, string $attribute): ?string
225
    {
226 442
        $path = $this->normalizePath($attribute);
227
228 442
        $value = $this;
229 442
        $n = 0;
230 442
        foreach ($path as $key) {
231 442
            if ($value instanceof self) {
232 442
                $nestedAttribute = implode('.', array_slice($path, $n));
233 442
                $data = match ($metaKey) {
234 442
                    self::META_LABEL => $value->getAttributeLabels(),
235 442
                    self::META_HINT => $value->getAttributeHints(),
236 442
                    self::META_PLACEHOLDER => $value->getAttributePlaceholders(),
237 442
                    default => throw new InvalidArgumentException('Invalid meta key.'),
238 442
                };
239 442
                if (array_key_exists($nestedAttribute, $data)) {
240 214
                    return $data[$nestedAttribute];
241
                }
242
            }
243
244 348
            $class = new ReflectionClass($value);
245
            try {
246 348
                $property = $class->getProperty($key);
247 3
            } catch (ReflectionException) {
248 3
                return null;
249
            }
250 345
            if ($property->isStatic()) {
251
                return null;
252
            }
253
254 345
            if (PHP_VERSION_ID < 80100) {
255 345
                $property->setAccessible(true);
256
            }
257
            /** @var mixed $value */
258 345
            $value = $property->getValue($value);
259 345
            if (!is_object($value)) {
260 341
                return null;
261
            }
262
263 4
            $n++;
264
        }
265
266 1
        return null;
267
    }
268
269
    /**
270
     * Generates a user-friendly attribute label based on the give attribute name.
271
     *
272
     * This is done by replacing underscores, dashes and dots with blanks and changing the first letter of each word to
273
     * upper case.
274
     *
275
     * For example, 'department_name' or 'DepartmentName' will generate 'Department Name'.
276
     *
277
     * @param string $attribute The attribute name.
278
     *
279
     * @return string The attribute label.
280
     */
281 25
    private function generateAttributeLabel(string $attribute): string
282
    {
283 25
        if (self::$inflector === null) {
284 1
            self::$inflector = new Inflector();
285
        }
286
287 25
        return StringHelper::uppercaseFirstCharacterInEachWord(
288 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

288
            self::$inflector->/** @scrutinizer ignore-call */ 
289
                              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...
289 25
        );
290
    }
291
292
    /**
293
     * @return string[]
294
     */
295 500
    private function normalizePath(string $attribute): array
296
    {
297 500
        $attribute = str_replace(['][', '['], '.', rtrim($attribute, ']'));
298 500
        return StringHelper::parsePath($attribute);
299
    }
300
301
    /**
302
     * @psalm-param array<array-key, array{0:int|string, 1:mixed}> $keys
303
     */
304 7
    private function makePropertyPathString(array $keys): string
305
    {
306 7
        $path = '';
307 7
        foreach ($keys as $key) {
308 7
            if ($path !== '') {
309 7
                if (is_object($key[1])) {
310 7
                    $path .= '::' . $key[0];
311 1
                } elseif (is_array($key[1])) {
312 7
                    $path .= '[' . $key[0] . ']';
313
                }
314
            } else {
315 7
                $path = (string) $key[0];
316
            }
317
        }
318 7
        return $path;
319
    }
320
}
321