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

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