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

diego-ninja / granite / 19074186251

04 Nov 2025 03:39PM UTC coverage: 83.117% (+0.08%) from 83.036%
19074186251

push

github

web-flow
Merge pull request #19 from diego-ninja/feature/uuid_hydration

feature: uuid/ulid/custom-id hydration

26 of 28 new or added lines in 1 file covered. (92.86%)

2821 of 3394 relevant lines covered (83.12%)

16.77 hits per line

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

97.44
/src/Traits/HasTypeConversion.php
1
<?php
2

3
namespace Ninja\Granite\Traits;
4

5
use BackedEnum;
6
use DateMalformedStringException;
7
use DateTimeInterface;
8
use Exception;
9
use Ninja\Granite\Contracts\GraniteObject;
10
use Ninja\Granite\Serialization\Attributes\DateTimeProvider;
11
use Ninja\Granite\Support\CarbonSupport;
12
use ReflectionNamedType;
13
use ReflectionProperty;
14
use ReflectionType;
15
use ReflectionUnionType;
16
use Throwable;
17
use UnitEnum;
18

19
/**
20
 * Trait providing type conversion functionality for Granite objects.
21
 * Handles conversion between different data types during deserialization.
22
 */
23
trait HasTypeConversion
24
{
25
    /**
26
     * Convert value to Carbon instance.
27
     * This method will be provided by HasCarbonSupport trait.
28
     *
29
     * @param mixed $value Value to convert
30
     * @param string $typeName Target Carbon type name
31
     * @param ReflectionProperty|null $property Property for attribute access
32
     * @param DateTimeProvider|null $classProvider Class-level provider
33
     * @return DateTimeInterface|null Carbon instance
34
     */
35
    abstract protected static function convertToCarbon(
36
        mixed $value,
37
        string $typeName,
38
        ?ReflectionProperty $property = null,
39
        ?DateTimeProvider $classProvider = null,
40
    ): ?DateTimeInterface;
41

42
    /**
43
     * Convert value to DateTime instance (with possible Carbon auto-conversion).
44
     * This method will be provided by HasCarbonSupport trait.
45
     *
46
     * @param mixed $value Value to convert
47
     * @param string $typeName Target DateTime type name
48
     * @param ReflectionProperty|null $property Property for attribute access
49
     * @param DateTimeProvider|null $classProvider Class-level provider
50
     * @return DateTimeInterface|null DateTime instance
51
     * @throws DateMalformedStringException
52
     */
53
    abstract protected static function convertToDateTime(
54
        mixed $value,
55
        string $typeName,
56
        ?ReflectionProperty $property = null,
57
        ?DateTimeProvider $classProvider = null,
58
    ): ?DateTimeInterface;
59
    /**
60
     * @param mixed $value The value to convert
61
     * @param ReflectionType|null $type The target type
62
     * @param ReflectionProperty|null $property The property being converted (for attribute access)
63
     * @param DateTimeProvider|null $classProvider Class-level DateTime provider
64
     * @return mixed Converted value
65
     * @throws DateMalformedStringException
66
     */
67
    protected static function convertValueToType(
172✔
68
        mixed $value,
69
        ?ReflectionType $type,
70
        ?ReflectionProperty $property = null,
71
        ?DateTimeProvider $classProvider = null,
72
    ): mixed {
73
        if (null === $value) {
172✔
74
            return null;
11✔
75
        }
76

77
        if ($type instanceof ReflectionNamedType) {
168✔
78
            return self::convertToNamedType($value, $type, $property, $classProvider);
164✔
79
        }
80

81
        if ($type instanceof ReflectionUnionType) {
4✔
82
            return self::convertToUnionType($value, $type, $property, $classProvider);
2✔
83
        }
84

85
        return $value;
2✔
86
    }
87

88
    /**
89
     * @param mixed $value The value to convert
90
     * @param ReflectionNamedType $type The target type
91
     * @param ReflectionProperty|null $property The property being converted
92
     * @param DateTimeProvider|null $classProvider Class-level DateTime provider
93
     * @return mixed Converted value
94
     * @throws DateMalformedStringException
95
     * @throws Exception
96
     */
97
    private static function convertToNamedType(
178✔
98
        mixed $value,
99
        ReflectionNamedType $type,
100
        ?ReflectionProperty $property = null,
101
        ?DateTimeProvider $classProvider = null,
102
    ): mixed {
103
        /** @var class-string $typeName */
104
        $typeName = $type->getName();
178✔
105

106
        // Check for Carbon classes first (before general DateTime check)
107
        if (CarbonSupport::isCarbonClass($typeName)) {
178✔
108
            return self::convertToCarbon($value, $typeName, $property, $classProvider);
13✔
109
        }
110

111
        // Check for UUID/ULID classes
112
        if ( ! $type->isBuiltin()) {
165✔
113
            $uuidResult = self::convertToUuidLike($value, $typeName);
50✔
114
            if ($uuidResult !== $value) {
50✔
115
                return $uuidResult;
6✔
116
            }
117
        }
118

119
        // Check for GraniteObject first
120
        if (is_subclass_of($typeName, GraniteObject::class)) {
160✔
121
            if (null === $value) {
8✔
122
                return null;
1✔
123
            }
124

125
            return $typeName::from($value);
7✔
126
        }
127

128
        // Check for DateTime
129
        if (DateTimeInterface::class === $typeName || is_subclass_of($typeName, DateTimeInterface::class)) {
159✔
130
            return self::convertToDateTime($value, $typeName, $property, $classProvider);
14✔
131
        }
132

133
        // Check for Enum (PHP 8.1+)
134
        if (interface_exists('UnitEnum') && is_subclass_of($typeName, UnitEnum::class)) {
156✔
135
            // If value is already the correct enum instance
136
            if ($value instanceof $typeName) {
22✔
137
                return $value;
6✔
138
            }
139

140
            // Try to convert string/int to an enum case
141
            if (is_string($value) || is_int($value)) {
16✔
142
                // For BackedEnum (with values)
143
                if (is_subclass_of($typeName, BackedEnum::class)) {
15✔
144
                    $enum = $typeName::tryFrom($value);
13✔
145
                    if (null === $enum) {
13✔
146
                        return self::defaultCase($typeName, 'Unknown');
4✔
147
                    }
148
                    return $enum;
9✔
149
                }
150

151
                // For UnitEnum (without values)
152
                foreach ($typeName::cases() as $case) {
2✔
153
                    if ($case->name === $value) {
2✔
154
                        return $case;
2✔
155
                    }
156
                }
157
            }
158

159
            // If we couldn't convert to an enum, return null
160
            return null;
1✔
161
        }
162

163
        return $value;
144✔
164
    }
165

166
    /**
167
     * @param mixed $value The value to convert
168
     * @param ReflectionUnionType $type The union type
169
     * @param ReflectionProperty|null $property Property for attribute access
170
     * @param DateTimeProvider|null $classProvider Class-level provider
171
     * @return mixed Converted value
172
     * @throws DateMalformedStringException
173
     * @throws Exception
174
     */
175
    private static function convertToUnionType(
7✔
176
        mixed $value,
177
        ReflectionUnionType $type,
178
        ?ReflectionProperty $property = null,
179
        ?DateTimeProvider $classProvider = null,
180
    ): mixed {
181
        foreach ($type->getTypes() as $unionType) {
7✔
182
            if ($unionType instanceof ReflectionNamedType) {
7✔
183
                $typeName = $unionType->getName();
7✔
184

185
                // Try Carbon types first
186
                if (CarbonSupport::isCarbonClass($typeName)) {
7✔
187
                    $result = self::convertToCarbon($value, $typeName, $property, $classProvider);
1✔
188
                    if (null !== $result) {
1✔
189
                        return $result;
1✔
190
                    }
191
                }
192

193
                // Try DateTime types
194
                if (DateTimeInterface::class === $typeName || is_subclass_of($typeName, DateTimeInterface::class)) {
6✔
195
                    $result = self::convertToDateTime($value, $typeName, $property, $classProvider);
2✔
196
                    if (null !== $result) {
2✔
197
                        return $result;
2✔
198
                    }
199
                }
200
            }
201
        }
202

203
        return $value;
4✔
204
    }
205

206
    /**
207
     * @template T of BackedEnum
208
     * @param class-string<T> $enumClass
209
     * @param string $caseName
210
     * @return BackedEnum|null
211
     */
212
    private static function defaultCase(string $enumClass, string $caseName): ?BackedEnum
4✔
213
    {
214
        foreach ($enumClass::cases() as $case) {
4✔
215
            if ($case->name === $caseName) {
4✔
216
                return $case;
2✔
217
            }
218
        }
219

220
        return null;
2✔
221
    }
222

223
    /**
224
     * Convert value to UUID/ULID instance using hybrid detection.
225
     *
226
     * @param mixed $value Value to convert
227
     * @param class-string $typeName Target type name
228
     * @return mixed Converted UUID/ULID instance or original value
229
     */
230
    private static function convertToUuidLike(mixed $value, string $typeName): mixed
55✔
231
    {
232
        // Step 1: Check known libraries
233
        if (interface_exists('Ramsey\Uuid\UuidInterface')
55✔
234
            && is_subclass_of($typeName, 'Ramsey\Uuid\UuidInterface')) {
55✔
NEW
235
            return self::tryCreateFromValue($value, $typeName);
×
236
        }
237

238
        if (class_exists('Symfony\Component\Uid\AbstractUid')
55✔
239
            && is_subclass_of($typeName, 'Symfony\Component\Uid\AbstractUid')) {
55✔
NEW
240
            return self::tryCreateFromValue($value, $typeName);
×
241
        }
242

243
        // Step 2: Duck-typing for custom ID classes
244
        if (self::looksLikeIdClass($typeName)) {
55✔
245
            return self::tryCreateFromValue($value, $typeName);
11✔
246
        }
247

248
        // Not a UUID-like type, return original value
249
        return $value;
44✔
250
    }
251

252
    /**
253
     * Try to create an instance from a value using from() or fromString() factory methods.
254
     *
255
     * @param mixed $value Value to convert
256
     * @param class-string $className Target class name
257
     * @return mixed Created instance or original value if conversion failed
258
     */
259
    private static function tryCreateFromValue(mixed $value, string $className): mixed
17✔
260
    {
261
        // Already correct type
262
        if ($value instanceof $className) {
17✔
263
            return $value;
1✔
264
        }
265

266
        // Try from() first
267
        if (method_exists($className, 'from')) {
16✔
268
            try {
269
                return $className::from($value);
12✔
270
            } catch (Throwable) {
3✔
271
                // Fall through to fromString
272
            }
273
        }
274

275
        // Try fromString() as fallback
276
        if (method_exists($className, 'fromString')) {
8✔
277
            try {
278
                return $className::fromString($value);
7✔
279
            } catch (Throwable) {
3✔
280
                // Both failed, return original
281
            }
282
        }
283

284
        // Couldn't convert, return original value unchanged
285
        return $value;
4✔
286
    }
287

288
    /**
289
     * Check if a class name looks like an ID class based on naming heuristics.
290
     *
291
     * @param string $className Fully qualified class name
292
     * @return bool True if class name contains uuid, ulid, uid, or id
293
     */
294
    private static function looksLikeIdClass(string $className): bool
56✔
295
    {
296
        $parts = explode('\\', $className);
56✔
297
        $baseName = end($parts);
56✔
298
        return (bool) preg_match('/uuid|ulid|uid|id/i', $baseName);
56✔
299
    }
300
}
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