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

DoclerLabs / api-client-generator / 36425526272

28 Sep 2026 12:57PM UTC coverage: 87.753% (+0.4%) from 87.359%
36425526272

Pull #163

github

tommey
docs: add changelog entry for 11.4.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pull Request #163: fix: remaining code generation bugs found by regenerating 42 clients (11.4.0)

473 of 527 new or added lines in 24 files covered. (89.75%)

3941 of 4491 relevant lines covered (87.75%)

10.64 hits per line

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

97.37
/src/Input/ClassNameCollisionResolver.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace DoclerLabs\ApiClientGenerator\Input;
6

7
use cebe\openapi\SpecObjectInterface;
8
use DoclerLabs\ApiClientGenerator\Ast\PhpVersion;
9
use DoclerLabs\ApiClientGenerator\Entity\Constraint\ConstraintInterface;
10
use DoclerLabs\ApiClientGenerator\Entity\Field;
11
use DoclerLabs\ApiClientGenerator\Naming\SchemaCollectionNaming;
12

13
/**
14
 * Different schemas can end up with the same class name, e.g. an inline enum property `Call.status` and an inline
15
 * enum query parameter `callStatus` are both named `CallStatusEnum`. Only one class can be generated for a name, so
16
 * every other schema with that name gets a numeric suffix (`CallStatus2Enum`), instead of silently using the class
17
 * of a different schema. Schemas with identical definitions keep sharing their class.
18
 *
19
 * The class keeps the definition that it had before: the named component schema, if one of the definitions is a
20
 * component referenced by its name, otherwise the definition that the generators used to emit (the last one).
21
 */
22
class ClassNameCollisionResolver
23
{
24
    private const ENUM_SUFFIX = 'Enum';
25

26
    public function __construct(private PhpVersion $phpVersion)
27
    {
28
    }
5 ✔
29

30
    public function resolve(Specification $specification): void
31
    {
32
        $fieldsByClassName = [];
5 ✔
33
        $operationsByField = [];
5 ✔
34
        foreach ($specification->getOperations() as $operation) {
5 ✔
35
            foreach ($operation->request->fields as $field) {
5 ✔
36
                $this->collectNamedFields($field, $operation->name, $fieldsByClassName, $operationsByField);
4 ✔
37
            }
38
            foreach ($operation->successfulResponses as $response) {
5 ✔
39
                if ($response->body !== null) {
5 ✔
40
                    $this->collectNamedFields($response->body, $operation->name, $fieldsByClassName, $operationsByField);
5 ✔
41
                }
42
            }
43
        }
44

45
        $takenClassNames = $this->getTakenClassNames($fieldsByClassName);
5 ✔
46
        $emittedFields   = $this->getEmittedFields($specification);
5 ✔
47
        foreach ($fieldsByClassName as $className => $fields) {
5 ✔
48
            if (count($fields) < 2) {
5 ✔
49
                continue;
5 ✔
50
            }
51

52
            $definitions = [];
4 ✔
53
            foreach ($fields as $field) {
4 ✔
54
                $definitions[$this->getSignature($field)][] = $field;
4 ✔
55
            }
56

57
            if (count($definitions) < 2) {
4 ✔
58
                continue;
3 ✔
59
            }
60

61
            $keptSignature = $this->getKeptSignature($definitions, $emittedFields[$className] ?? null);
2 ✔
62
            foreach ($definitions as $signature => $definitionFields) {
2 ✔
63
                if ($signature === $keptSignature) {
2 ✔
64
                    continue;
2 ✔
65
                }
66

67
                $newClassName      = $this->getUniqueClassName((string)$className, $takenClassNames);
2 ✔
68
                $takenClassNames[] = $newClassName;
2 ✔
69
                $operationNames    = [];
2 ✔
70
                foreach ($definitionFields as $field) {
2 ✔
71
                    $field->setReferenceName($newClassName);
2 ✔
72
                    $operationNames[] = $operationsByField[spl_object_id($field)];
2 ✔
73
                }
74

75
                $warningMessage = sprintf(
2 ✔
76
                    'Class name %s is shared by different schemas, %s is used for the one in %s. Consider giving the schemas distinct names.',
2 ✔
77
                    $className,
2 ✔
78
                    $newClassName,
2 ✔
79
                    implode(', ', array_unique($operationNames))
2 ✔
80
                );
2 ✔
81
                trigger_error($warningMessage, E_USER_WARNING);
2 ✔
82
            }
83
        }
84
    }
85

86
    /**
87
     * @param array<string, Field[]> $fieldsByClassName
88
     * @param array<int, string>     $operationsByField
89
     */
90
    private function collectNamedFields(
91
        Field $field,
92
        string $operationName,
93
        array &$fieldsByClassName,
94
        array &$operationsByField
95
    ): void {
96
        if ($field->isObject() || ($field->isEnum() && $this->phpVersion->isEnumSupported())) {
5 ✔
97
            $fieldsByClassName[$field->getPhpClassName()][] = $field;
5 ✔
98
            $operationsByField[spl_object_id($field)]       = $operationName;
5 ✔
99
        }
100

101
        if ($field->isArray()) {
5 ✔
102
            $this->collectNamedFields($field->getArrayItem(), $operationName, $fieldsByClassName, $operationsByField);
3 ✔
103
        }
104

105
        foreach ($field->getObjectProperties() as $property) {
5 ✔
106
            $this->collectNamedFields($property, $operationName, $fieldsByClassName, $operationsByField);
5 ✔
107
        }
108
    }
109

110
    /**
111
     * The fields whose definition the generators emit for a class name when names collide, see the generators.
112
     *
113
     * @return array<string, Field>
114
     */
115
    private function getEmittedFields(Specification $specification): array
116
    {
117
        $emittedFields   = [];
5 ✔
118
        $compositeFields = $specification->getCompositeFields()->getUniqueByPhpClassName();
5 ✔
119
        if ($this->phpVersion->isEnumSupported()) {
5 ✔
120
            // EnumGenerator
121
            foreach ($compositeFields as $field) {
1 ✔
122
                if ($field->isObject() && !$field->isFreeFormObject()) {
1 ✔
123
                    foreach ($field->getObjectProperties() as $propertyField) {
1 ✔
124
                        if ($propertyField->isEnum()) {
1 ✔
125
                            $emittedFields[$propertyField->getPhpClassName()] = $propertyField;
1 ✔
126
                        }
127
                    }
128
                }
129
                if ($field->isEnum()) {
1 ✔
NEW
130
                    $emittedFields[$field->getPhpClassName()] = $field;
×
131
                }
132
            }
133
            foreach ($specification->getOperations() as $operation) {
1 ✔
134
                foreach ($operation->request->fields as $field) {
1 ✔
135
                    if ($field->isEnum()) {
1 ✔
136
                        $emittedFields[$field->getPhpClassName()] = $field;
1 ✔
137
                    }
138
                }
139
            }
140
        }
141

142
        // SchemaGenerator and FreeFormSchemaGenerator, registered after the enums
143
        foreach ($compositeFields as $className => $field) {
5 ✔
144
            if ($field->isObject()) {
5 ✔
145
                $emittedFields[$className] = $field;
5 ✔
146
            }
147
        }
148

149
        return $emittedFields;
5 ✔
150
    }
151

152
    /**
153
     * @param array<string, Field[]> $definitions
154
     */
155
    private function getKeptSignature(array $definitions, ?Field $emittedField): string
156
    {
157
        $explicitSignatures = [];
2 ✔
158
        foreach ($definitions as $signature => $fields) {
2 ✔
159
            foreach ($fields as $field) {
2 ✔
160
                if ($field->hasExplicitReferenceName()) {
2 ✔
161
                    $explicitSignatures[] = (string)$signature;
2 ✔
162

163
                    break;
2 ✔
164
                }
165
            }
166
        }
167

168
        $candidates       = empty($explicitSignatures) ? array_map('strval', array_keys($definitions)) : $explicitSignatures;
2 ✔
169
        $emittedSignature = $emittedField === null ? null : $this->getSignature($emittedField);
2 ✔
170
        if ($emittedSignature !== null && in_array($emittedSignature, $candidates, true)) {
2 ✔
171
            return $emittedSignature;
1 ✔
172
        }
173

174
        return (string)end($candidates);
2 ✔
175
    }
176

177
    /**
178
     * @param string[] $takenClassNames
179
     */
180
    private function getUniqueClassName(string $className, array $takenClassNames): string
181
    {
182
        $suffix = str_ends_with($className, self::ENUM_SUFFIX) ? self::ENUM_SUFFIX : '';
2 ✔
183
        $base   = substr($className, 0, strlen($className) - strlen($suffix));
2 ✔
184

185
        $number = 2;
2 ✔
186
        while (in_array($base . $number . $suffix, $takenClassNames, true)) {
2 ✔
NEW
187
            ++$number;
×
188
        }
189

190
        return $base . $number . $suffix;
2 ✔
191
    }
192

193
    /**
194
     * @param array<string, Field[]> $fieldsByClassName
195
     *
196
     * @return string[]
197
     */
198
    private function getTakenClassNames(array $fieldsByClassName): array
199
    {
200
        $takenClassNames = [];
5 ✔
201
        foreach ($fieldsByClassName as $className => $fields) {
5 ✔
202
            $takenClassNames[] = (string)$className;
5 ✔
203
            foreach ($fields as $field) {
5 ✔
204
                $takenClassNames[] = SchemaCollectionNaming::getClassName($field->getReferenceName());
5 ✔
205
            }
206
        }
207

208
        return array_values(array_unique($takenClassNames));
5 ✔
209
    }
210

211
    /**
212
     * Identifies the definition of the class that a field is generated to: fields with the same signature are
213
     * generated to the same code (apart from the names of the classes they refer to).
214
     */
215
    private function getSignature(Field $field): string
216
    {
217
        return serialize($this->describeDefinition($field));
4 ✔
218
    }
219

220
    private function describeDefinition(Field $field): array
221
    {
222
        if ($field->isEnum()) {
4 ✔
223
            $values = $field->getEnumValues() ?? [];
3 ✔
224
            sort($values);
3 ✔
225

226
            return ['enum', $field->getType()->toSpecificationType(), $values];
3 ✔
227
        }
228

229
        if ($field->isObject()) {
4 ✔
230
            $discriminator = $field->getDiscriminator();
4 ✔
231

232
            return [
4 ✔
233
                'object',
4 ✔
234
                $field->isFreeFormObject(),
4 ✔
235
                $field->hasOneOf(),
4 ✔
236
                $field->hasAnyOf(),
4 ✔
237
                $discriminator instanceof SpecObjectInterface ? $discriminator->getSerializableData() : $discriminator,
4 ✔
238
                array_map(fn (Field $property): array => $this->describeProperty($property), $field->getObjectProperties()),
4 ✔
239
            ];
4 ✔
240
        }
241

242
        if ($field->isArray()) {
4 ✔
243
            // the name of an inline item depends on the operation, e.g. `{Operation}{Field}Item`
244
            return ['array', $this->describeProperty($field->getArrayItem(), false)];
2 ✔
245
        }
246

247
        return [$field->getType()->toSpecificationType(), $field->getFormat()];
4 ✔
248
    }
249

250
    private function describeProperty(Field $field, bool $withName = true): array
251
    {
252
        $constraints = [];
4 ✔
253
        foreach ($field->getConstraints() as $constraint) {
4 ✔
254
            /** @var ConstraintInterface $constraint */
255
            if ($constraint->exists()) {
4 ✔
NEW
256
                $constraints[] = [get_class($constraint), $constraint->getExceptionMessage()];
×
257
            }
258
        }
259

260
        return [
4 ✔
261
            $withName ? $field->getName() : null,
4 ✔
262
            $field->isRequired(),
4 ✔
263
            $field->isNullable(),
4 ✔
264
            $field->getDefault(),
4 ✔
265
            $constraints,
4 ✔
266
            $this->describeDefinition($field),
4 ✔
267
        ];
4 ✔
268
    }
269
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc