• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
Info updated!

strictlyPHP / dolphin / #39

27 Dec 2025 01:02PM UTC coverage: 88.439% (-0.01%) from 88.45%
#39

push

web-flow
Merge pull request #65 from strictlyPHP/fix-dto-mapper-array-types

fix incorrect parsing of array types in dto mapper

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

2 existing lines in 1 file now uncovered.

306 of 346 relevant lines covered (88.44%)

4.82 hits per line

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

84.96
/src/Strategy/DtoMapper.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace StrictlyPHP\Dolphin\Strategy;
6

7
use PhpParser\Lexer\Emulative;
8
use PhpParser\Node;
9
use PhpParser\Node\Stmt\UseUse;
10
use PhpParser\Parser\Php8;
11
use ReflectionClass;
12
use ReflectionNamedType;
13
use ReflectionParameter;
14
use StrictlyPHP\Dolphin\Strategy\Exception\ArrayTypeNotDeclaredException;
15
use StrictlyPHP\Dolphin\Strategy\Exception\DtoMapperException;
16

17
class DtoMapper
18
{
19
    /**
20
     * @var array<string, array<string,string>>
21
     */
22
    private array $importMapCache = [];
23

24
    /**
25
     * @param array<mixed, mixed> $data
26
     */
27
    public function map(string $dtoClass, array $data): object
28
    {
29
        $refClass = new ReflectionClass($dtoClass);
15✔
30
        $constructor = $refClass->getConstructor();
15✔
31

32
        if (! $constructor) {
15✔
33
            return new $dtoClass();
×
34
        }
35

36
        $args = [];
15✔
37

38
        foreach ($constructor->getParameters() as $param) {
15✔
39
            $name = $param->getName();
15✔
40
            $type = $param->getType();
15✔
41

42
            $hasValue = array_key_exists($name, $data);
15✔
43
            $raw = $hasValue ? $data[$name] : null;
15✔
44

45
            // Handle nullables
46
            if ($raw === null) {
15✔
47
                if ($type instanceof ReflectionNamedType && $type->allowsNull()) {
2✔
48
                    $args[] = null;
1✔
49
                    continue;
1✔
50
                }
51

52
                // Non-nullable -> error
53
                throw new DtoMapperException(
1✔
54
                    sprintf("Missing non-nullable parameter '%s'", $name)
1✔
55
                );
1✔
56
            }
57

58
            // Array type (possibly nullable)
59
            if ($this->isArrayParam($param)) {
14✔
60
                try {
61
                    $elementClass = $this->resolveArrayDocblockType($param);
3✔
62
                    $allowsNull = $this->arrayAllowsNullElements($param);
3✔
63
                    $args[] = $this->mapArrayOfType($elementClass, $raw, $allowsNull);
3✔
64
                } catch (ArrayTypeNotDeclaredException $e) {
3✔
65
                    // No element type declared, treat as plain array
66
                    $args[] = $raw;
3✔
67
                }
68
                continue;
3✔
69
            }
70

71

72
            // Object type (DTO / enum / value object)
73
            if ($type instanceof ReflectionNamedType && ! $type->isBuiltin()) {
12✔
74
                $className = $type->getName();
11✔
75
                $args[] = $this->mapSingleValue($className, $raw);
11✔
76
                continue;
10✔
77
            }
78

79
            // Scalar
80
            $args[] = $raw;
8✔
81
        }
82

83
        return $refClass->newInstanceArgs($args);
12✔
84
    }
85

86
    // ---------------------------------------------------------
87
    // Helpers
88
    // ---------------------------------------------------------
89

90
    private function isArrayParam(ReflectionParameter $param): bool
91
    {
92
        $type = $param->getType();
14✔
93
        return $type instanceof ReflectionNamedType && $type->getName() === 'array';
14✔
94
    }
95

96
    private function resolveArrayDocblockType(ReflectionParameter $param): string
97
    {
98
        $rawDoc = $param->getDeclaringFunction()->getDocComment() ?: '';
3✔
99
        $paramName = $param->getName();
3✔
100

101
        // collapse whitespace
102
        $doc = preg_replace('/\s+/', ' ', $rawDoc);
3✔
103

104
        $pattern =
3✔
105
            '/@param\s+array\s*<\s*' .
3✔
106
            '(?:[\w\\\\]+\s*,\s*)?' .      // optional key type: int,
3✔
107
            '(\??[\w\\\\]+)\s*' .          // capture value type: ?Geography
3✔
108
            '>\s*' .
3✔
109
            '(?:\|\s*null)?\s*' .          // allow trailing union: |null
3✔
110
            '\$' . preg_quote($paramName, '/') .
3✔
111
            '\b/i';
3✔
112

113
        if (preg_match($pattern, $doc, $m)) {
3✔
114
            $type = ltrim($m[1], '?');
3✔
115

116
            // primitive?
117
            if (in_array(strtolower($type), ['string', 'int', 'float', 'bool'], true)) {
3✔
118
                return $type;
3✔
119
            }
120

121
            // Try to resolve class name via imports
122
            $fqcn = $this->resolveClassNameFromImports($param, $type);
3✔
123
            return $fqcn;
3✔
124
        }
125

126
        throw new ArrayTypeNotDeclaredException("Cannot determine array element type for parameter $paramName");
3✔
127
    }
128

129
    private function resolveClassNameFromImports(ReflectionParameter $param, string $shortName): string
130
    {
131
        $dtoClass = $param->getDeclaringClass()->getName();
3✔
132
        $importMap = $this->getImportMapForDto($dtoClass);
3✔
133

134
        // If the short name matches an import alias, return that
135
        if (isset($importMap[$shortName])) {
3✔
136
            return $importMap[$shortName];
3✔
137
        }
138

139
        // Otherwise fallback to namespace of the DTO
140
        $dtoNamespace = $param->getDeclaringClass()->getNamespaceName();
×
141
        $candidate = $dtoNamespace . '\\' . $shortName;
×
142

143
        if (class_exists($candidate)) {
×
144
            return $candidate;
×
145
        }
146

147
        throw new DtoMapperException("Cannot resolve class name '$shortName' for parameter {$param->getName()}");
×
148
    }
149

150
    /**
151
     * @return array<string, string> alias => fqcn
152
     */
153
    private function getImportMapForDto(string $dtoClass): array
154
    {
155
        if (isset($this->importMapCache[$dtoClass])) {
3✔
156
            return $this->importMapCache[$dtoClass];
3✔
157
        }
158

159
        $ref = new ReflectionClass($dtoClass);
3✔
160
        $file = $ref->getFileName();
3✔
161
        $imports = $this->parseUseStatementsFromFile($file);
3✔
162

163
        $this->importMapCache[$dtoClass] = $imports;
3✔
164
        return $imports;
3✔
165
    }
166

167
    /**
168
     * Parse a file and extract `use` statements (class imports) using PHP-Parser
169
     *
170
     * @return array<string, string> alias => fqcn (fully qualified class name)
171
     */
172
    private function parseUseStatementsFromFile(string $filePath): array
173
    {
174
        $code = file_get_contents($filePath);
3✔
175
        if ($code === false) {
3✔
176
            throw new DtoMapperException("Cannot read file for class imports: $filePath");
×
177
        }
178

179
        $parser = new Php8(new Emulative());
3✔
180

181
        try {
182
            $ast = $parser->parse($code);
3✔
183
        } catch (\PhpParser\Error $e) {
×
184
            throw new DtoMapperException("PHP parser error on $filePath: {$e->getMessage()}");
×
185
        }
186

187
        $imports = [];
3✔
188

189
        if (! is_array($ast)) {
3✔
190
            return $imports;
×
191
        }
192

193
        foreach ($ast as $node) {
3✔
194
            // If file has a namespace
195
            if ($node instanceof Node\Stmt\Namespace_) {
3✔
196
                foreach ($node->stmts as $stmt) {
3✔
197
                    if ($stmt instanceof Node\Stmt\Use_) {
3✔
198
                        foreach ($stmt->uses as $use) {
3✔
199
                            /** @var UseUse $use */
200
                            $alias = $use->alias ? $use->alias->name : $use->name->getLast();
3✔
201
                            $fqcn = $use->name->toString(); // fully qualified class name
3✔
202
                            $imports[$alias] = $fqcn;
3✔
203
                        }
204
                    }
205
                }
206
                break; // only process the first namespace
3✔
207
            }
208

209
            // Global namespace use statements
210
            if ($node instanceof Node\Stmt\Use_) {
3✔
211
                foreach ($node->uses as $use) {
×
212
                    /** @var UseUse $use */
213
                    $alias = $use->alias ? $use->alias->name : $use->name->getLast();
×
214
                    $fqcn = $use->name->toString();
×
215
                    $imports[$alias] = $fqcn;
×
216
                }
217
            }
218
        }
219

220
        return $imports;
3✔
221
    }
222

223
    private function arrayAllowsNullElements(ReflectionParameter $param): bool
224
    {
225
        $doc = $param->getDeclaringFunction()->getDocComment() ?: '';
3✔
226
        return preg_match('/array<\w+,\s*\?/', $doc) === 1;
3✔
227
    }
228

229
    /**
230
     * @param class-string $className
231
     */
232
    private function mapSingleValue(string $className, mixed $value): mixed
233
    {
234
        $ref = new ReflectionClass($className);
11✔
235

236
        // enum
237
        if ($ref->isEnum()) {
11✔
238
            return $this->newEnum($className, $value);
2✔
239
        }
240

241
        // nested DTO
242
        if (is_array($value)) {
9✔
243
            return $this->map($className, $value);
7✔
244
        }
245

246
        // primitive value object (e.g. Uuid)
247
        return $ref->newInstanceArgs([$value]);
9✔
248
    }
249

250
    /**
251
     * @param array<mixed> $values
252
     * @return array<mixed>
253
     */
254
    private function mapArrayOfType(string $elementClass, array $values, bool $nullableElements): array
255
    {
256
        $out = [];
3✔
257
        foreach ($values as $val) {
3✔
258
            if ($val === null) {
2✔
UNCOV
259
                if ($nullableElements) {
×
260
                    $out[] = null;
×
261
                    continue;
×
262
                }
UNCOV
263
                throw new DtoMapperException("Null array element not allowed for $elementClass");
×
264
            }
265

266
            // scalar types
267
            if (in_array($elementClass, ['string', 'int', 'float', 'bool'], true)) {
2✔
268
                $out[] = $val;
1✔
269
                continue;
1✔
270
            }
271

272
            // already object of correct type
273
            if (is_object($val) && $val instanceof $elementClass) {
2✔
274
                $out[] = $val;
×
275
                continue;
×
276
            }
277

278
            // array → recursively map DTO
279
            if (is_array($val)) {
2✔
280
                $out[] = $this->map($elementClass, $val);
1✔
281
                continue;
1✔
282
            }
283

284
            // single-value constructor objects (e.g., EmailAddress)
285
            $ref = new \ReflectionClass($elementClass);
2✔
286

287
            // Handle enums
288
            if ($ref->isEnum()) {
2✔
289
                $out[] = $this->newEnum($elementClass, $val);
2✔
290
            } else {
291
                // Non-enum single-value constructor objects
292
                $out[] = $ref->newInstanceArgs([$val]);
1✔
293
            }
294
        }
295
        return $out;
3✔
296
    }
297

298
    /**
299
     * @param class-string $enumClass
300
     */
301
    private function newEnum(string $enumClass, mixed $value): \BackedEnum
302
    {
303
        $enumRef = new \ReflectionEnum($enumClass);
4✔
304

305
        if ($enumRef->isBacked()) {
4✔
306
            // For backed enums, use tryFrom for safe conversion
307
            if (($enum = $enumClass::tryFrom($value)) === null) {
3✔
308
                throw new DtoMapperException(sprintf(
1✔
309
                    'Could not map value "%s" to enum "%s"',
1✔
310
                    is_scalar($value) ? (string) $value : gettype($value),
1✔
311
                    $enumClass
1✔
312
                ));
1✔
313
            }
314
            return $enum;
2✔
315
        } else {
316
            throw new DtoMapperException(sprintf(
1✔
317
                'Could not map unit enum "%s". Unit enums are not allowed. consider turning it into a backed enum',
1✔
318
                $enumClass
1✔
319
            ));
1✔
320
        }
321
    }
322
}
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