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

strictlyPHP / dolphin / #27

17 Nov 2025 12:34PM UTC coverage: 88.487% (-0.3%) from 88.742%
#27

push

web-flow
Merge pull request #52 from strictlyPHP/use-parser-5

use nikic/php-parser 5+

5 of 7 new or added lines in 1 file covered. (71.43%)

1 existing line in 1 file now uncovered.

269 of 304 relevant lines covered (88.49%)

4.08 hits per line

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

84.48
/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\DtoMapperException;
15

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

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

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

35
        $args = [];
13✔
36

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

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

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

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

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

70

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

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

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

85
    // ---------------------------------------------------------
86
    // Helpers
87
    // ---------------------------------------------------------
88

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

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

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

103
        if (
104
            preg_match(
3✔
105
                '/@param\s+array\s*<\s*(?:[\w\\\\]+\s*,\s*)?(\??[\w\\\\]+)\s*>\s+\$' .
3✔
106
                preg_quote($paramName, '/') .
3✔
107
                '/i',
3✔
108
                $doc,
3✔
109
                $m
3✔
110
            )
3✔
111
        ) {
112
            $type = ltrim($m[1], '?');
3✔
113

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

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

124
        throw new DtoMapperException("Cannot determine array element type for parameter $paramName");
3✔
125
    }
126

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

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

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

141
        if (class_exists($candidate)) {
×
142
            return $candidate;
×
143
        }
144

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

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

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

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

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

177
        $parser = new Php8(new Emulative());
3✔
178

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

185
        $imports = [];
3✔
186

187
        if (! is_array($ast)) {
3✔
NEW
188
            return $imports;
×
189
        }
190

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

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

218
        return $imports;
3✔
219
    }
220

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

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

234
        // enum
235
        if ($ref->isEnum()) {
9✔
236
            return $className::from($value);
1✔
237
        }
238

239
        // nested DTO
240
        if (is_array($value)) {
8✔
241
            return $this->map($className, $value);
6✔
242
        }
243

244
        // primitive value object (e.g. Uuid)
245
        return $ref->newInstanceArgs([$value]);
8✔
246
    }
247

248
    /**
249
     * @param array<mixed> $values
250
     * @return array<mixed>
251
     */
252
    private function mapArrayOfType(string $elementClass, array $values, bool $nullableElements): array
253
    {
254
        $out = [];
3✔
255

256
        foreach ($values as $val) {
3✔
257
            if ($val === null) {
3✔
258
                if ($nullableElements) {
1✔
259
                    $out[] = null;
×
260
                    continue;
×
261
                }
262
                throw new DtoMapperException("Null array element not allowed for $elementClass");
1✔
263
            }
264

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

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

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

283
            // single-value constructor objects (e.g., EmailAddress)
284
            $ref = new \ReflectionClass($elementClass);
3✔
285
            $out[] = $ref->newInstanceArgs([$val]);
3✔
286
        }
287

288
        return $out;
3✔
289
    }
290
}
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