Passed
Push — master ( 777f51...4fd9e5 )
by Sergei
03:52 queued 56s
created

FormModel::readPropertyValue()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 36
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 7

Importance

Changes 0
Metric Value
cc 7
eloc 23
nc 7
nop 1
dl 0
loc 36
ccs 23
cts 23
cp 1
crap 7
rs 8.6186
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Form\YiisoftFormModel;
6
7
use InvalidArgumentException;
8
use ReflectionClass;
9
use ReflectionException;
10
use Yiisoft\Form\YiisoftFormModel\Exception\PropertyNotSupportNestedValuesException;
11
use Yiisoft\Form\YiisoftFormModel\Exception\StaticObjectPropertyException;
12
use Yiisoft\Form\YiisoftFormModel\Exception\UndefinedArrayElementException;
13
use Yiisoft\Form\YiisoftFormModel\Exception\UndefinedObjectPropertyException;
14
use Yiisoft\Form\YiisoftFormModel\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 property.
42
     *
43
     * @param string $property The property name.
44
     *
45
     * @return string The property hint.
46
     */
47 411
    public function getPropertyHint(string $property): string
48
    {
49 411
        return $this->readPropertyMetaValue(self::META_HINT, $property) ?? '';
50
    }
51
52
    /**
53
     * Returns the property hints.
54
     *
55
     * Property hints are mainly used for display purpose. For example, given a property `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 property 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 Property hints (name => hint)
65
     *
66
     * @psalm-return array<string,string>
67
     */
68 100
    public function getPropertyHints(): array
69
    {
70 100
        return [];
71
    }
72
73
    /**
74
     * Returns the text label for the specified property.
75
     *
76
     * @param string $property The property name.
77
     *
78
     * @return string The property label.
79
     */
80 214
    public function getPropertyLabel(string $property): string
81
    {
82 214
        return $this->readPropertyMetaValue(self::META_LABEL, $property) ?? $this->generatePropertyLabel($property);
83
    }
84
85
    /**
86
     * Returns the property labels.
87
     *
88
     * Attribute labels are mainly used for display purpose. For example, given a property `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, a property label is generated automatically. This method allows you to
92
     * explicitly specify property 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 Property labels (name => label)
98
     *
99
     * @psalm-return array<string,string>
100
     */
101 6
    public function getPropertyLabels(): array
102
    {
103 6
        return [];
104
    }
105
106
    /**
107
     * Returns the text placeholder for the specified property.
108
     *
109
     * @param string $property The property name.
110
     *
111
     * @return string The property placeholder.
112
     */
113 187
    public function getPropertyPlaceholder(string $property): string
114
    {
115 187
        return $this->readPropertyMetaValue(self::META_PLACEHOLDER, $property) ?? '';
116
    }
117
118 391
    public function getPropertyValue(string $property): mixed
119
    {
120
        try {
121 391
            return $this->readPropertyValue($property);
122 6
        } catch (PropertyNotSupportNestedValuesException $exception) {
123 2
            return $exception->getValue() === null
124 1
                ? null
125 1
                : throw $exception;
126 4
        } catch (UndefinedArrayElementException) {
127 2
            return null;
128
        }
129
    }
130
131
    /**
132
     * Returns the property placeholders.
133
     *
134
     * @return array Property placeholders (name => placeholder)
135
     *
136
     * @psalm-return array<string,string>
137
     */
138 123
    public function getPropertyPlaceholders(): array
139
    {
140 123
        return [];
141
    }
142
143
    /**
144
     * Returns the form name that this model class should use.
145
     *
146
     * The form name is mainly used by {@see \Yiisoft\Form\InputData\HtmlForm} to determine how to name the input
147
     * fields for the properties in a model.
148
     * If the form name is "A" and a property name is "b", then the corresponding input name would be "A[b]".
149
     * If the form name is an empty string, then the input name would be "b".
150
     *
151
     * The purpose of the above naming schema is that for forms which contain multiple different models, the properties
152
     * of each model are grouped in sub-arrays of the POST-data, and it is easier to differentiate between them.
153
     *
154
     * By default, this method returns the model class name (without the namespace part) as the form name. You may
155
     * override it when the model is used in different forms.
156
     *
157
     * @return string The form name of this model class.
158
     */
159 424
    public function getFormName(): string
160
    {
161 424
        if (str_contains(static::class, '@anonymous')) {
162 3
            return '';
163
        }
164
165 422
        $className = strrchr(static::class, '\\');
166 422
        if ($className === false) {
167 1
            return static::class;
168
        }
169
170 421
        return substr($className, 1);
171
    }
172
173
    /**
174
     * If there is such property in the set.
175
     */
176 476
    public function hasProperty(string $property): bool
177
    {
178
        try {
179 476
            $this->readPropertyValue($property);
180 7
        } catch (ValueNotFoundException) {
181 7
            return false;
182
        }
183 471
        return true;
184
    }
185
186
    public function isValid(): bool
187
    {
188
        return (bool) $this->getValidationResult()?->isValid();
189
    }
190
191
    /**
192
     * @throws UndefinedArrayElementException
193
     * @throws UndefinedObjectPropertyException
194
     * @throws StaticObjectPropertyException
195
     * @throws PropertyNotSupportNestedValuesException
196
     * @throws ValueNotFoundException
197
     */
198 497
    private function readPropertyValue(string $path): mixed
199
    {
200 497
        $path = $this->normalizePath($path);
201
202 497
        $value = $this;
203 497
        $keys = [[static::class, $this]];
204 497
        foreach ($path as $key) {
205 497
            $keys[] = [$key, $value];
206
207 497
            if (is_array($value)) {
208 4
                if (array_key_exists($key, $value)) {
209 2
                    $value = $value[$key];
210 2
                    continue;
211
                }
212 2
                throw new UndefinedArrayElementException($this->makePropertyPathString($keys));
213
            }
214
215 497
            if (is_object($value)) {
216 497
                $class = new ReflectionClass($value);
217
                try {
218 497
                    $property = $class->getProperty($key);
219 5
                } catch (ReflectionException) {
220 5
                    throw new UndefinedObjectPropertyException($this->makePropertyPathString($keys));
221
                }
222 496
                if ($property->isStatic()) {
223 1
                    throw new StaticObjectPropertyException($this->makePropertyPathString($keys));
224
                }
225 496
                $value = $property->getValue($value);
226 496
                continue;
227
            }
228
229 2
            array_pop($keys);
230 2
            throw new PropertyNotSupportNestedValuesException($this->makePropertyPathString($keys), $value);
231
        }
232
233 491
        return $value;
234
    }
235
236 448
    private function readPropertyMetaValue(int $metaKey, string $path): ?string
237
    {
238 448
        $path = $this->normalizePath($path);
239
240 448
        $value = $this;
241 448
        $n = 0;
242 448
        foreach ($path as $key) {
243 448
            if ($value instanceof self) {
244 448
                $nestedAttribute = implode('.', array_slice($path, $n));
245 448
                $data = match ($metaKey) {
246 448
                    self::META_LABEL => $value->getPropertyLabels(),
247 448
                    self::META_HINT => $value->getPropertyHints(),
248 448
                    self::META_PLACEHOLDER => $value->getPropertyPlaceholders(),
249 448
                    default => throw new InvalidArgumentException('Invalid meta key.'),
250 448
                };
251 448
                if (array_key_exists($nestedAttribute, $data)) {
252 219
                    return $data[$nestedAttribute];
253
                }
254
            }
255
256 348
            $class = new ReflectionClass($value);
257
            try {
258 348
                $property = $class->getProperty($key);
259 3
            } catch (ReflectionException) {
260 3
                return null;
261
            }
262 345
            if ($property->isStatic()) {
263
                return null;
264
            }
265
266 345
            $value = $property->getValue($value);
267 345
            if (!is_object($value)) {
268 341
                return null;
269
            }
270
271 4
            $n++;
272
        }
273
274 1
        return null;
275
    }
276
277
    /**
278
     * Generates a user-friendly property label based on the give property name.
279
     *
280
     * This is done by replacing underscores, dashes and dots with blanks and changing the first letter of each word to
281
     * upper case.
282
     *
283
     * For example, 'department_name' or 'DepartmentName' will generate 'Department Name'.
284
     *
285
     * @param string $property The property name.
286
     *
287
     * @return string The property label.
288
     */
289 26
    private function generatePropertyLabel(string $property): string
290
    {
291 26
        if (self::$inflector === null) {
292 1
            self::$inflector = new Inflector();
293
        }
294
295 26
        return StringHelper::uppercaseFirstCharacterInEachWord(
296 26
            self::$inflector->toWords($property)
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

296
            self::$inflector->/** @scrutinizer ignore-call */ 
297
                              toWords($property)

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...
297 26
        );
298
    }
299
300
    /**
301
     * @return string[]
302
     */
303 503
    private function normalizePath(string $path): array
304
    {
305 503
        $path = str_replace(['][', '['], '.', rtrim($path, ']'));
306 503
        return StringHelper::parsePath($path);
307
    }
308
309
    /**
310
     * @psalm-param array<array-key, array{0:int|string, 1:mixed}> $keys
311
     */
312 9
    private function makePropertyPathString(array $keys): string
313
    {
314 9
        $path = '';
315 9
        foreach ($keys as $key) {
316 9
            if ($path !== '') {
317 9
                if (is_object($key[1])) {
318 9
                    $path .= '::' . $key[0];
319 2
                } elseif (is_array($key[1])) {
320 9
                    $path .= '[' . $key[0] . ']';
321
                }
322
            } else {
323 9
                $path = (string) $key[0];
324
            }
325
        }
326 9
        return $path;
327
    }
328
}
329