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

diego-ninja / granite / 22631927117

03 Mar 2026 04:13PM UTC coverage: 83.371% (+0.3%) from 83.117%
22631927117

push

github

web-flow
Merge pull request #20 from diego-ninja/feature/performance-optimizations

Performance optimizations & README reorganization

172 of 191 new or added lines in 7 files covered. (90.05%)

2 existing lines in 2 files now uncovered.

2973 of 3566 relevant lines covered (83.37%)

16.23 hits per line

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

86.47
/src/Support/ClassProfile.php
1
<?php
2

3
// ABOUTME: Pre-computed class metadata for fast-path object creation.
4
// ABOUTME: Detects simple DTOs (primitive or Granite types, no attributes) to bypass the hydration pipeline.
5

6
namespace Ninja\Granite\Support;
7

8
use Error;
9
use Ninja\Granite\Contracts\GraniteObject;
10
use Ninja\Granite\Granite;
11
use Ninja\Granite\Serialization\Attributes\CarbonDate;
12
use Ninja\Granite\Serialization\Attributes\DateTimeProvider;
13
use Ninja\Granite\Serialization\Attributes\Hidden;
14
use Ninja\Granite\Serialization\Attributes\SerializationConvention;
15
use Ninja\Granite\Serialization\Attributes\SerializedName;
16
use ReflectionAttribute;
17
use ReflectionClass;
18
use ReflectionException;
19
use ReflectionNamedType;
20
use ReflectionParameter;
21
use ReflectionProperty;
22
use stdClass;
23

24
final class ClassProfile
25
{
26
    private const array BUILTIN_TYPES = ['int', 'string', 'float', 'bool', 'array', 'null'];
27

28
    /** @var array<string, mixed> Ordered map of param name => default value or REQUIRED sentinel */
29
    public readonly array $constructorParams;
30

31
    public readonly bool $canUseFastPath;
32

33
    /** @var array<string, class-string> Param name => Granite subclass for non-primitive params */
34
    public readonly array $graniteParams;
35

36
    /** @var class-string */
37
    private readonly string $className;
38

39
    /** @var string[] Param names in constructor order */
40
    private readonly array $paramNames;
41

42
    /**
43
     * @param class-string $className
44
     * @param array<string, mixed> $constructorParams
45
     * @param string[] $paramNames
46
     * @param array<string, class-string> $graniteParams
47
     */
48
    private function __construct(string $className, array $constructorParams, array $paramNames, bool $canUseFastPath, array $graniteParams = [])
67✔
49
    {
50
        $this->className = $className;
67✔
51
        $this->constructorParams = $constructorParams;
67✔
52
        $this->paramNames = $paramNames;
67✔
53
        $this->canUseFastPath = $canUseFastPath;
67✔
54
        $this->graniteParams = $graniteParams;
67✔
55
    }
56

57
    /**
58
     * @param class-string $class
59
     */
60
    public static function build(string $class): self
67✔
61
    {
62
        $reflection = ReflectionCache::getClass($class);
67✔
63
        $constructor = $reflection->getConstructor();
67✔
64

65
        if (null === $constructor) {
67✔
66
            return new self($class, [], [], false, []);
5✔
67
        }
68

69
        $params = [];
62✔
70
        /** @var array<string, class-string> $graniteParams */
71
        $graniteParams = [];
62✔
72
        $canUseFastPath = ! self::hasDisqualifyingClassAttributes($reflection)
62✔
73
            && ! self::hasOverriddenRules($reflection)
62✔
74
            && ! self::hasReadonlyParentProperties($reflection);
62✔
75

76
        foreach ($constructor->getParameters() as $param) {
62✔
77
            if ($canUseFastPath && ! self::isFastPathParameter($param, $graniteParams)) {
62✔
78
                $canUseFastPath = false;
4✔
79
            }
80

81
            if ($canUseFastPath && self::hasDisqualifyingPropertyAttributes($reflection, $param->getName())) {
62✔
82
                $canUseFastPath = false;
4✔
83
            }
84

85
            if ($param->isDefaultValueAvailable()) {
62✔
86
                $params[$param->getName()] = $param->getDefaultValue();
29✔
87
            } else {
88
                $params[$param->getName()] = self::required();
45✔
89
            }
90
        }
91

92
        // Ensure all public properties are covered by constructor params
93
        if ($canUseFastPath) {
62✔
94
            $publicProps = $reflection->getProperties(ReflectionProperty::IS_PUBLIC);
40✔
95
            if (count($publicProps) !== count($params)) {
40✔
NEW
96
                $canUseFastPath = false;
×
97
            }
98
        }
99

100
        if ( ! $canUseFastPath) {
62✔
101
            $graniteParams = [];
22✔
102
        }
103

104
        return new self($class, $params, array_keys($params), $canUseFastPath, $graniteParams);
62✔
105
    }
106

107
    /**
108
     * @return object|null Created instance, or null to fall back to slow path
109
     */
110
    public function tryFastPath(array $args): ?object
76✔
111
    {
112
        if (array_is_list($args)) {
76✔
113
            if (1 === count($args) && is_array($args[0])) {
66✔
114
                $data = $args[0];
42✔
115
            } else {
116
                return null;
26✔
117
            }
118
        } else {
119
            $data = $args;
13✔
120
        }
121

122
        $className = $this->className;
53✔
123

124
        // Pure-primitive DTOs: use C-level array_intersect_key + named args unpacking
125
        if (empty($this->graniteParams)) {
53✔
126
            $filtered = array_intersect_key($data, $this->constructorParams);
53✔
127
            if (count($filtered) !== count($this->paramNames)) {
53✔
128
                return null;
10✔
129
            }
130

131
            return new $className(...$filtered);
44✔
132
        }
133

134
        // DTOs with Granite-typed params: need per-param conversion
135
        $constructorArgs = [];
3✔
136
        foreach ($this->paramNames as $name) {
3✔
137
            if ( ! array_key_exists($name, $data)) {
3✔
NEW
138
                return null;
×
139
            }
140

141
            $value = $data[$name];
3✔
142

143
            if (isset($this->graniteParams[$name])) {
3✔
144
                if (is_array($value)) {
3✔
145
                    $graniteClass = $this->graniteParams[$name];
2✔
146
                    $value = $graniteClass::from($value);
2✔
147
                } elseif (null !== $value && ! $value instanceof GraniteObject) {
1✔
NEW
148
                    return null;
×
149
                }
150
            }
151

152
            $constructorArgs[] = $value;
3✔
153
        }
154

155
        return new $className(...$constructorArgs);
3✔
156
    }
157

158
    /**
159
     * Compare two instances by direct property access, with early exit on first difference.
160
     */
161
    public function areEqual(object $a, object $b): bool
4✔
162
    {
163
        if (empty($this->graniteParams)) {
4✔
164
            foreach ($this->paramNames as $name) {
4✔
165
                if ($a->{$name} !== $b->{$name}) {
4✔
166
                    return false;
2✔
167
                }
168
            }
169

170
            return true;
2✔
171
        }
172

NEW
173
        foreach ($this->paramNames as $name) {
×
NEW
174
            $va = $a->{$name};
×
NEW
175
            $vb = $b->{$name};
×
176

NEW
177
            if ($va instanceof Granite && $vb instanceof Granite) {
×
NEW
178
                if ( ! $va->equals($vb)) {
×
NEW
179
                    return false;
×
180
                }
NEW
181
            } elseif ($va !== $vb) {
×
NEW
182
                return false;
×
183
            }
184
        }
185

NEW
186
        return true;
×
187
    }
188

189
    /**
190
     * Build array representation by direct property access, bypassing reflection and metadata.
191
     *
192
     * @return array<string, mixed>
193
     */
194
    public function toArray(object $instance): array
8✔
195
    {
196
        $result = [];
8✔
197
        foreach ($this->paramNames as $name) {
8✔
198
            try {
199
                $value = $instance->{$name};
8✔
200
            } catch (Error) {
1✔
201
                continue;
1✔
202
            }
203

204
            if (isset($this->graniteParams[$name]) && $value instanceof GraniteObject) {
8✔
205
                $value = $value->array();
1✔
206
            }
207

208
            $result[$name] = $value;
8✔
209
        }
210

211
        return $result;
8✔
212
    }
213

214
    /**
215
     * Check if a parameter type is compatible with the fast path.
216
     * Returns true for builtin types and Granite subclasses.
217
     * For Granite subclasses, the class name is added to $graniteParams.
218
     *
219
     * @param array<string, class-string> $graniteParams Collects Granite-typed param names
220
     */
221
    private static function isFastPathParameter(ReflectionParameter $param, array &$graniteParams): bool
48✔
222
    {
223
        $type = $param->getType();
48✔
224

225
        if (null === $type || ! $type instanceof ReflectionNamedType) {
48✔
NEW
226
            return false;
×
227
        }
228

229
        $typeName = $type->getName();
48✔
230

231
        if (in_array($typeName, self::BUILTIN_TYPES, true)) {
48✔
232
            return true;
47✔
233
        }
234

235
        if (is_subclass_of($typeName, GraniteObject::class)) {
11✔
236
            $graniteParams[$param->getName()] = $typeName;
7✔
237
            return true;
7✔
238
        }
239

240
        return false;
4✔
241
    }
242

243
    /**
244
     * @param ReflectionClass<object> $reflection
245
     */
246
    private static function hasDisqualifyingClassAttributes(ReflectionClass $reflection): bool
62✔
247
    {
248
        $disqualifying = [
62✔
249
            SerializationConvention::class,
62✔
250
            DateTimeProvider::class,
62✔
251
        ];
62✔
252

253
        foreach ($disqualifying as $attrClass) {
62✔
254
            if ( ! empty($reflection->getAttributes($attrClass, ReflectionAttribute::IS_INSTANCEOF))) {
62✔
255
                return true;
10✔
256
            }
257
        }
258

259
        return false;
52✔
260
    }
261

262
    /**
263
     * @param ReflectionClass<object> $reflection
264
     */
265
    private static function hasDisqualifyingPropertyAttributes(ReflectionClass $reflection, string $propertyName): bool
47✔
266
    {
267
        $property = null;
47✔
268
        try {
269
            $property = $reflection->getProperty($propertyName);
47✔
NEW
270
        } catch (ReflectionException) {
×
NEW
271
            return false;
×
272
        }
273

274
        $attributes = $property->getAttributes();
47✔
275
        foreach ($attributes as $attr) {
47✔
276
            $attrName = $attr->getName();
7✔
277
            if (SerializedName::class === $attrName
7✔
278
                || Hidden::class === $attrName
7✔
279
                || is_subclass_of($attrName, \Ninja\Granite\Validation\Rules\AbstractRule::class)
5✔
280
                || (class_exists(CarbonDate::class) && CarbonDate::class === $attrName)
7✔
281
            ) {
282
                return true;
2✔
283
            }
284

285
            // Check for validation attributes (any attribute that has asRule())
286
            if (method_exists($attrName, 'asRule')) {
5✔
287
                return true;
2✔
288
            }
289
        }
290

291
        return false;
45✔
292
    }
293

294
    /**
295
     * @param ReflectionClass<object> $reflection
296
     */
297
    private static function hasOverriddenRules(ReflectionClass $reflection): bool
52✔
298
    {
299
        try {
300
            $rulesMethod = $reflection->getMethod('rules');
52✔
301
            // If the declaring class is not Granite's HasValidation trait host,
302
            // then the method has been overridden
303
            $declaringClass = $rulesMethod->getDeclaringClass()->getName();
52✔
304

305
            // rules() is defined in HasValidation trait, used by Granite.
306
            // If declaring class matches the concrete class, it's overridden.
307
            return $declaringClass === $reflection->getName();
52✔
NEW
308
        } catch (ReflectionException) {
×
NEW
309
            return false;
×
310
        }
311
    }
312

313
    /**
314
     * @param ReflectionClass<object> $reflection
315
     */
316
    private static function hasReadonlyParentProperties(ReflectionClass $reflection): bool
48✔
317
    {
318
        $properties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC);
48✔
319
        foreach ($properties as $property) {
48✔
320
            if ($property->isReadOnly() && $property->getDeclaringClass()->getName() !== $reflection->getName()) {
48✔
NEW
321
                return true;
×
322
            }
323
        }
324

325
        return false;
48✔
326
    }
327

328
    private static function required(): stdClass
45✔
329
    {
330
        /** @var stdClass|null $sentinel */
331
        static $sentinel = null;
45✔
332
        $sentinel ??= new stdClass();
45✔
333

334
        return $sentinel;
45✔
335
    }
336
}
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