• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

diego-ninja / granite / 16608963009

29 Jul 2025 10:37PM UTC coverage: 50.423%. First build
16608963009

Pull #5

github

web-flow
Merge 43d8840d7 into 6a6caca51
Pull Request #5: code: adds phpstan level max, pint with per and github actions

321 of 632 new or added lines in 77 files covered. (50.79%)

1132 of 2245 relevant lines covered (50.42%)

9.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

68.35
/src/Serialization/MetadataCache.php
1
<?php
2

3
namespace Ninja\Granite\Serialization;
4

5
use Exception;
6
use Ninja\Granite\Mapping\Contracts\NamingConvention;
7
use Ninja\Granite\Mapping\Conventions\CamelCaseConvention;
8
use Ninja\Granite\Serialization\Attributes\Hidden;
9
use Ninja\Granite\Serialization\Attributes\SerializationConvention;
10
use Ninja\Granite\Serialization\Attributes\SerializedName;
11
use Ninja\Granite\Support\ReflectionCache;
12
use ReflectionAttribute;
13
use ReflectionException;
14
use ReflectionMethod;
15

16
class MetadataCache
17
{
18
    /**
19
     * Cache of serialization metadata by class name.
20
     *
21
     * @var array<string, Metadata>
22
     */
23
    private static array $metadataCache = [];
24

25
    /**
26
     * Get serialization metadata for a class.
27
     *
28
     * @param class-string $class Class name
29
     * @return Metadata Serialization metadata
30
     * @throws \Ninja\Granite\Exceptions\ReflectionException
31
     */
32
    public static function getMetadata(string $class): Metadata
48✔
33
    {
34
        if ( ! isset(self::$metadataCache[$class])) {
48✔
35
            self::$metadataCache[$class] = self::buildMetadata($class);
41✔
36
        }
37

38
        return self::$metadataCache[$class];
47✔
39
    }
40

41
    /**
42
     * Check if a data key could represent the given PHP property via the convention.
43
     *
44
     * @param string $dataKey Key from input data
45
     * @param string $phpPropertyName PHP property name
46
     * @param NamingConvention $convention The naming convention to use for comparison
47
     * @return bool Whether they represent the same logical property
48
     */
NEW
49
    public static function conventionMatches(string $dataKey, string $phpPropertyName, NamingConvention $convention): bool
×
50
    {
51
        try {
52
            // Strategy 1: Normalize the data key using the target convention
NEW
53
            $dataKeyNormalized = null;
×
NEW
54
            if ($convention->matches($dataKey)) {
×
NEW
55
                $dataKeyNormalized = $convention->normalize($dataKey);
×
56
            }
57

58
            // Strategy 2: Normalize the PHP property name (assume camelCase source)
NEW
59
            $sourceConvention = new CamelCaseConvention();
×
NEW
60
            $phpPropertyNormalized = null;
×
61

NEW
62
            if ($sourceConvention->matches($phpPropertyName)) {
×
NEW
63
                $phpPropertyNormalized = $sourceConvention->normalize($phpPropertyName);
×
64
            } else {
65
                // Fallback normalization for PHP property names
NEW
66
                $converted = preg_replace('/(?<!^)([A-Z])/', ' $1', $phpPropertyName);
×
NEW
67
                $phpPropertyNormalized = mb_strtolower($converted ?? $phpPropertyName);
×
68
            }
69

70
            // Strategy 3: Compare normalized forms
NEW
71
            if (null !== $dataKeyNormalized) {
×
NEW
72
                return $dataKeyNormalized === $phpPropertyNormalized;
×
73
            }
74

75
            // Strategy 4: Try direct conversion - convert PHP property to target convention and compare
NEW
76
            $phpPropertyInTargetConvention = self::convertPropertyNameToConvention($phpPropertyName, $convention);
×
NEW
77
            return (bool) ($phpPropertyInTargetConvention === $dataKey)
×
78

79

80

NEW
81
            ;
×
82

NEW
83
        } catch (Exception $e) {
×
NEW
84
            return false;
×
85
        }
86
    }
87

88
    /**
89
     * Clear the metadata cache.
90
     * Useful for testing or when class definitions change dynamically.
91
     */
92
    public static function clearCache(): void
16✔
93
    {
94
        self::$metadataCache = [];
16✔
95
    }
96

97
    /**
98
     * Build serialization metadata for a class.
99
     *
100
     * @param class-string $class Class name
101
     * @return Metadata Built metadata
102
     * @throws \Ninja\Granite\Exceptions\ReflectionException
103
     */
104
    private static function buildMetadata(string $class): Metadata
41✔
105
    {
106
        $metadata = new Metadata();
41✔
107
        $reflection = ReflectionCache::getClass($class);
41✔
108

109
        // Check for class-level SerializationConvention attribute
110
        $classConvention = self::getClassConvention($class);
40✔
111

112
        // Check if the class has serializedNames() method and call it using reflection
113
        if (method_exists($class, 'serializedNames')) {
40✔
114
            $propertyNames = self::invokeProtectedStaticMethod($class, 'serializedNames');
36✔
115
            if (is_array($propertyNames)) {
36✔
116
                foreach ($propertyNames as $propName => $serializedName) {
36✔
117
                    if (is_string($propName) && is_string($serializedName)) {
5✔
118
                        $metadata->mapPropertyName($propName, $serializedName);
5✔
119
                    }
120
                }
121
            }
122
        }
123

124
        // Check if the class has hiddenProperties() method and call it using reflection
125
        if (method_exists($class, 'hiddenProperties')) {
40✔
126
            $hiddenProps = self::invokeProtectedStaticMethod($class, 'hiddenProperties');
36✔
127
            if (is_array($hiddenProps)) {
36✔
128
                foreach ($hiddenProps as $propName) {
36✔
129
                    if (is_string($propName)) {
5✔
130
                        $metadata->hideProperty($propName);
5✔
131
                    }
132
                }
133
            }
134
        }
135

136
        // Check for property attributes (PHP 8+)
137
        $properties = ReflectionCache::getPublicProperties($class);
40✔
138

139
        foreach ($properties as $property) {
40✔
140
            $propertyName = $property->getName();
40✔
141

142
            // Check for SerializedName attribute (takes precedence over convention)
143
            $nameAttrs = $property->getAttributes(SerializedName::class, ReflectionAttribute::IS_INSTANCEOF);
40✔
144
            if ( ! empty($nameAttrs)) {
40✔
145
                $attr = $nameAttrs[0]->newInstance();
8✔
146
                $metadata->mapPropertyName($propertyName, $attr->name);
8✔
147
            }
148
            // Apply class convention if no explicit SerializedName
149
            elseif (null !== $classConvention) {
40✔
150
                $conventionName = self::convertPropertyNameToConvention($propertyName, $classConvention);
15✔
151
                if ($conventionName !== $propertyName) {
15✔
152
                    $metadata->mapPropertyName($propertyName, $conventionName);
15✔
153
                }
154
            }
155

156
            // Check for Hidden attribute
157
            $hiddenAttrs = $property->getAttributes(Hidden::class, ReflectionAttribute::IS_INSTANCEOF);
40✔
158
            if ( ! empty($hiddenAttrs)) {
40✔
159
                $metadata->hideProperty($propertyName);
8✔
160
            }
161
        }
162

163
        return $metadata;
40✔
164
    }
165

166
    /**
167
     * Get the class-level naming convention if defined.
168
     *
169
     * @param class-string $class Class name
170
     * @return NamingConvention|null The naming convention or null if not defined
171
     */
172
    private static function getClassConvention(string $class): ?NamingConvention
40✔
173
    {
174
        try {
175
            $reflection = ReflectionCache::getClass($class);
40✔
176
            $conventionAttrs = $reflection->getAttributes(SerializationConvention::class, ReflectionAttribute::IS_INSTANCEOF);
40✔
177

178
            if (empty($conventionAttrs)) {
40✔
179
                return null;
25✔
180
            }
181

182
            $conventionAttr = $conventionAttrs[0]->newInstance();
15✔
183
            return $conventionAttr->getConvention();
15✔
NEW
184
        } catch (Exception $e) {
×
185
            // Log error and return null to fallback gracefully
186
            return null;
×
187
        }
188
    }
189

190
    /**
191
     * Convert a PHP property name (assumed camelCase) to the target convention.
192
     *
193
     * @param string $propertyName The PHP property name (typically camelCase)
194
     * @param NamingConvention $targetConvention The target naming convention
195
     * @return string The converted property name
196
     */
197
    private static function convertPropertyNameToConvention(string $propertyName, NamingConvention $targetConvention): string
15✔
198
    {
199
        try {
200
            // Step 1: Detect the source convention (assume camelCase for PHP properties)
201
            $sourceConvention = new CamelCaseConvention();
15✔
202

203
            // Step 2: Normalize the property name to a standard form
204
            $normalized = $propertyName;
15✔
205

206
            // If the property name matches camelCase convention, normalize it
207
            if ($sourceConvention->matches($propertyName)) {
15✔
208
                $normalized = $sourceConvention->normalize($propertyName);
15✔
209
            } else {
210
                // Fallback: try to convert camelCase-like strings to normalized form
211
                // Example: "firstName" -> "first name", "XMLHttpRequest" -> "xml http request"
NEW
212
                $converted = preg_replace('/(?<!^)([A-Z])/', ' $1', $propertyName);
×
NEW
213
                $normalized = mb_strtolower($converted ?? $propertyName);
×
214
            }
215

216
            // Step 3: Convert normalized form to target convention
217
            return $targetConvention->denormalize($normalized);
15✔
218

NEW
219
        } catch (Exception $e) {
×
220
            // Return original name if conversion fails
221
            return $propertyName;
×
222
        }
223
    }
224

225
    /**
226
     * Invoke a protected static method using reflection.
227
     *
228
     * @param string $class Class name
229
     * @param string $methodName Method name
230
     * @return mixed Method result
231
     * @throws \Ninja\Granite\Exceptions\ReflectionException
232
     */
233
    private static function invokeProtectedStaticMethod(string $class, string $methodName): mixed
36✔
234
    {
235
        try {
236
            $reflectionMethod = new ReflectionMethod($class, $methodName);
36✔
237
            $reflectionMethod->setAccessible(true);
36✔
238
            return $reflectionMethod->invoke(null);
36✔
239
        } catch (ReflectionException $e) {
×
240
            throw new \Ninja\Granite\Exceptions\ReflectionException($class, $methodName, $e->getMessage());
×
241
        }
242
    }
243
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc