+117
-47
src/Generator/ClassGenerator.php
+117
-47
src/Generator/ClassGenerator.php
···
4
4
5
5
use SocialDept\Schema\Data\LexiconDocument;
6
6
use SocialDept\Schema\Exceptions\GenerationException;
7
+
use SocialDept\Schema\Support\ExtensionManager;
7
8
8
9
class ClassGenerator
9
10
{
···
33
34
protected DocBlockGenerator $docBlockGenerator;
34
35
35
36
/**
37
+
* Extension manager instance.
38
+
*/
39
+
protected ExtensionManager $extensions;
40
+
41
+
/**
36
42
* Create a new ClassGenerator.
37
43
*/
38
44
public function __construct(
···
40
46
?TypeMapper $typeMapper = null,
41
47
?StubRenderer $renderer = null,
42
48
?MethodGenerator $methodGenerator = null,
43
-
?DocBlockGenerator $docBlockGenerator = null
49
+
?DocBlockGenerator $docBlockGenerator = null,
50
+
?ExtensionManager $extensions = null
44
51
) {
45
-
$this->naming = $naming ?? new NamingConverter();
52
+
$this->naming = $naming ?? new NamingConverter;
46
53
$this->typeMapper = $typeMapper ?? new TypeMapper($this->naming);
47
-
$this->renderer = $renderer ?? new StubRenderer();
54
+
$this->renderer = $renderer ?? new StubRenderer;
48
55
$this->methodGenerator = $methodGenerator ?? new MethodGenerator($this->naming, $this->typeMapper, $this->renderer);
49
56
$this->docBlockGenerator = $docBlockGenerator ?? new DocBlockGenerator($this->typeMapper);
57
+
$this->extensions = $extensions ?? new ExtensionManager;
50
58
}
51
59
52
60
/**
···
69
77
['nsid' => $nsid, 'type' => $type]
70
78
);
71
79
}
80
+
81
+
// For record types, extract the actual record definition
82
+
$recordDef = $type === 'record' ? ($mainDef['record'] ?? []) : $mainDef;
83
+
84
+
// Build local definition map for type resolution
85
+
$localDefinitions = $this->buildLocalDefinitionMap($document);
86
+
$this->typeMapper->setLocalDefinitions($localDefinitions);
72
87
73
88
// Get class components
74
-
$namespace = $this->naming->nsidToNamespace($nsid);
75
-
$className = $this->naming->toClassName($document->id->getName());
76
-
$useStatements = $this->collectUseStatements($mainDef);
77
-
$properties = $this->generateProperties($mainDef);
78
-
$constructor = $this->generateConstructor($mainDef);
79
-
$methods = $this->generateMethods($document);
80
-
$docBlock = $this->generateClassDocBlock($document, $mainDef);
89
+
$namespace = $this->extensions->filter('filter:class:namespace', $this->naming->nsidToNamespace($nsid), $document);
90
+
$className = $this->extensions->filter('filter:class:className', $this->naming->toClassName($document->id->getName()), $document);
91
+
$useStatements = $this->extensions->filter('filter:class:useStatements', $this->collectUseStatements($recordDef, $namespace), $document, $recordDef);
92
+
$properties = $this->extensions->filter('filter:class:properties', $this->generateProperties($recordDef), $document, $recordDef);
93
+
$constructor = $this->extensions->filter('filter:class:constructor', $this->generateConstructor($recordDef), $document, $recordDef);
94
+
$methods = $this->extensions->filter('filter:class:methods', $this->generateMethods($document), $document);
95
+
$docBlock = $this->extensions->filter('filter:class:docBlock', $this->generateClassDocBlock($document, $mainDef), $document, $mainDef);
81
96
82
97
// Render the class
83
-
return $this->renderer->render('class', [
98
+
$rendered = $this->renderer->render('class', [
84
99
'namespace' => $namespace,
85
100
'imports' => $this->formatUseStatements($useStatements),
86
101
'docBlock' => $docBlock,
87
102
'className' => $className,
88
-
'extends' => ' extends \\SocialDept\\Schema\\Data\\Data',
103
+
'extends' => ' extends Data',
89
104
'implements' => '',
90
105
'properties' => $properties,
91
106
'constructor' => $constructor,
92
107
'methods' => $methods,
93
108
]);
109
+
110
+
// Execute post-generation hooks
111
+
$this->extensions->execute('action:class:generated', $rendered, $document);
112
+
113
+
return $rendered;
94
114
}
95
115
96
116
/**
97
117
* Generate class properties.
118
+
*
119
+
* Since we use constructor property promotion, we don't need separate property declarations.
120
+
* This method returns empty string but is kept for compatibility.
98
121
*
99
122
* @param array<string, mixed> $definition
100
123
*/
101
124
protected function generateProperties(array $definition): string
102
125
{
103
-
$properties = $definition['properties'] ?? [];
104
-
$required = $definition['required'] ?? [];
105
-
106
-
if (empty($properties)) {
107
-
return '';
108
-
}
109
-
110
-
$lines = [];
111
-
112
-
foreach ($properties as $name => $propDef) {
113
-
$isRequired = in_array($name, $required);
114
-
$phpType = $this->typeMapper->toPhpType($propDef, ! $isRequired);
115
-
$docType = $this->typeMapper->toPhpDocType($propDef, ! $isRequired);
116
-
$description = $propDef['description'] ?? null;
117
-
118
-
// Build property doc comment
119
-
$docLines = [' /**'];
120
-
if ($description) {
121
-
$docLines[] = ' * '.$description;
122
-
$docLines[] = ' *';
123
-
}
124
-
$docLines[] = ' * @var '.$docType;
125
-
$docLines[] = ' */';
126
-
127
-
$lines[] = implode("\n", $docLines);
128
-
$lines[] = ' public readonly '.$phpType.' $'.$name.';';
129
-
$lines[] = '';
130
-
}
131
-
132
-
return rtrim(implode("\n", $lines));
126
+
// Properties are defined via constructor promotion
127
+
return '';
133
128
}
134
129
135
130
/**
···
146
141
return '';
147
142
}
148
143
149
-
$params = [];
144
+
// Build constructor parameters - required first, then optional
145
+
$requiredParams = [];
146
+
$optionalParams = [];
147
+
$requiredDocParams = [];
148
+
$optionalDocParams = [];
150
149
151
150
foreach ($properties as $name => $propDef) {
152
151
$isRequired = in_array($name, $required);
153
152
$phpType = $this->typeMapper->toPhpType($propDef, ! $isRequired);
154
-
$default = ! $isRequired ? ' = null' : '';
153
+
$phpDocType = $this->typeMapper->toPhpDocType($propDef, ! $isRequired);
154
+
$description = $propDef['description'] ?? '';
155
+
$param = ' public readonly '.$phpType.' $'.$name;
155
156
156
-
$params[] = ' public readonly '.$phpType.' $'.$name.$default.',';
157
+
if ($isRequired) {
158
+
$requiredParams[] = $param.',';
159
+
if ($description) {
160
+
$requiredDocParams[] = ' * @param '.$phpDocType.' $'.$name.' '.$description;
161
+
}
162
+
} else {
163
+
$optionalParams[] = $param.' = null,';
164
+
if ($description) {
165
+
$optionalDocParams[] = ' * @param '.$phpDocType.' $'.$name.' '.$description;
166
+
}
167
+
}
157
168
}
158
169
170
+
// Combine required and optional parameters
171
+
$params = array_merge($requiredParams, $optionalParams);
172
+
159
173
// Remove trailing comma from last parameter
160
174
if (! empty($params)) {
161
175
$params[count($params) - 1] = rtrim($params[count($params) - 1], ',');
162
176
}
163
177
164
-
return " public function __construct(\n".implode("\n", $params)."\n ) {\n }";
178
+
// Build constructor DocBlock with parameter descriptions in the correct order
179
+
$docParams = array_merge($requiredDocParams, $optionalDocParams);
180
+
$docLines = [' /**'];
181
+
if (! empty($docParams)) {
182
+
$docLines = array_merge($docLines, $docParams);
183
+
}
184
+
$docLines[] = ' */';
185
+
$docBlock = implode("\n", $docLines);
186
+
187
+
return "\n".$docBlock."\n public function __construct(\n".implode("\n", $params)."\n ) {}";
165
188
}
166
189
167
190
/**
···
190
213
* @param array<string, mixed> $definition
191
214
* @return array<string>
192
215
*/
193
-
protected function collectUseStatements(array $definition): array
216
+
protected function collectUseStatements(array $definition, string $currentNamespace = ''): array
194
217
{
195
218
$uses = ['SocialDept\\Schema\\Data\\Data'];
196
219
$properties = $definition['properties'] ?? [];
···
208
231
209
232
// Remove duplicates and sort
210
233
$uses = array_unique($uses);
234
+
235
+
// Filter out classes from the same namespace
236
+
if ($currentNamespace) {
237
+
$uses = array_filter($uses, function ($use) use ($currentNamespace) {
238
+
// Get namespace from FQCN by removing class name
239
+
$parts = explode('\\', ltrim($use, '\\'));
240
+
array_pop($parts); // Remove class name
241
+
$useNamespace = implode('\\', $parts);
242
+
243
+
return $useNamespace !== $currentNamespace;
244
+
});
245
+
}
246
+
211
247
sort($uses);
212
248
213
249
return $uses;
···
233
269
}
234
270
235
271
/**
272
+
* Build a map of local definitions for type resolution.
273
+
*
274
+
* Maps local references (#defName) to their generated class names.
275
+
*
276
+
* @return array<string, string> Map of local ref => class name
277
+
*/
278
+
protected function buildLocalDefinitionMap(LexiconDocument $document): array
279
+
{
280
+
$localDefs = [];
281
+
$allDefs = $document->defs ?? [];
282
+
283
+
foreach ($allDefs as $defName => $definition) {
284
+
// Skip the main definition
285
+
if ($defName === 'main') {
286
+
continue;
287
+
}
288
+
289
+
// Convert definition name to class name
290
+
$className = $this->naming->toClassName($defName);
291
+
$localDefs["#{$defName}"] = $className;
292
+
}
293
+
294
+
return $localDefs;
295
+
}
296
+
297
+
/**
236
298
* Get the naming converter.
237
299
*/
238
300
public function getNaming(): NamingConverter
···
270
332
public function getDocBlockGenerator(): DocBlockGenerator
271
333
{
272
334
return $this->docBlockGenerator;
335
+
}
336
+
337
+
/**
338
+
* Get the extension manager.
339
+
*/
340
+
public function getExtensions(): ExtensionManager
341
+
{
342
+
return $this->extensions;
273
343
}
274
344
}
+3
-3
src/Generator/ConstructorGenerator.php
+3
-3
src/Generator/ConstructorGenerator.php
···
21
21
?PropertyGenerator $propertyGenerator = null,
22
22
?StubRenderer $renderer = null
23
23
) {
24
-
$this->propertyGenerator = $propertyGenerator ?? new PropertyGenerator();
25
-
$this->renderer = $renderer ?? new StubRenderer();
24
+
$this->propertyGenerator = $propertyGenerator ?? new PropertyGenerator;
25
+
$this->renderer = $renderer ?? new StubRenderer;
26
26
}
27
27
28
28
/**
···
162
162
$params."\n".
163
163
" ) {\n".
164
164
$body."\n".
165
-
" }";
165
+
' }';
166
166
}
167
167
168
168
/**
+44
-32
src/Generator/DTOGenerator.php
+44
-32
src/Generator/DTOGenerator.php
···
35
35
protected FileWriter $fileWriter;
36
36
37
37
/**
38
+
* Class generator for generating PHP classes.
39
+
*/
40
+
protected ClassGenerator $classGenerator;
41
+
42
+
/**
38
43
* Base namespace for generated classes.
39
44
*/
40
45
protected string $baseNamespace;
···
54
59
?TypeParser $typeParser = null,
55
60
?NamespaceResolver $namespaceResolver = null,
56
61
?TemplateRenderer $templateRenderer = null,
57
-
?FileWriter $fileWriter = null
62
+
?FileWriter $fileWriter = null,
63
+
?ClassGenerator $classGenerator = null
58
64
) {
59
65
$this->schemaLoader = $schemaLoader;
60
66
$this->baseNamespace = rtrim($baseNamespace, '\\');
61
67
$this->outputDirectory = rtrim($outputDirectory, '/');
62
68
$this->typeParser = $typeParser ?? new TypeParser(schemaLoader: $schemaLoader);
63
69
$this->namespaceResolver = $namespaceResolver ?? new NamespaceResolver($baseNamespace);
64
-
$this->templateRenderer = $templateRenderer ?? new TemplateRenderer();
65
-
$this->fileWriter = $fileWriter ?? new FileWriter();
70
+
$this->templateRenderer = $templateRenderer ?? new TemplateRenderer;
71
+
$this->fileWriter = $fileWriter ?? new FileWriter;
72
+
73
+
// Initialize ClassGenerator with proper naming converter
74
+
$naming = new NamingConverter($this->baseNamespace);
75
+
$this->classGenerator = $classGenerator ?? new ClassGenerator($naming);
66
76
}
67
77
68
78
/**
···
70
80
*/
71
81
public function generate(LexiconDocument $schema): string
72
82
{
73
-
return $this->generateRecordCode($schema);
83
+
return $this->classGenerator->generate($schema);
74
84
}
75
85
76
86
/**
···
114
124
*/
115
125
public function generateByNsid(string $nsid, array $options = []): array
116
126
{
117
-
$schema = $this->schemaLoader->load($nsid);
118
-
$document = LexiconDocument::fromArray($schema);
127
+
$document = $this->schemaLoader->load($nsid);
119
128
120
129
return $this->generateFromDocument($document, $options);
121
130
}
···
177
186
*/
178
187
protected function generateRecordClass(LexiconDocument $document, array $options = []): string
179
188
{
180
-
$namespace = $this->namespaceResolver->resolveNamespace($document->getNsid());
181
-
$className = $this->namespaceResolver->resolveClassName($document->getNsid());
182
-
183
-
$mainDef = $document->getMainDefinition();
184
-
$recordSchema = $mainDef['record'] ?? [];
185
-
186
-
$properties = $this->extractProperties($recordSchema, $document);
187
-
188
-
$code = $this->templateRenderer->render('record', [
189
-
'namespace' => $namespace,
190
-
'className' => $className,
191
-
'nsid' => $document->getNsid(),
192
-
'description' => $document->description,
193
-
'properties' => $properties,
194
-
]);
189
+
// Use ClassGenerator for proper code generation
190
+
$code = $this->classGenerator->generate($document);
195
191
192
+
$naming = $this->classGenerator->getNaming();
193
+
$namespace = $naming->nsidToNamespace($document->getNsid());
194
+
$className = $naming->toClassName($document->id->getName());
196
195
$filePath = $this->getFilePath($namespace, $className);
197
196
198
197
if (! ($options['dryRun'] ?? false)) {
···
207
206
*/
208
207
protected function generateDefinitionClass(LexiconDocument $document, string $defName, array $options = []): string
209
208
{
210
-
$namespace = $this->namespaceResolver->resolveNamespace($document->getNsid());
211
-
$className = $this->namespaceResolver->resolveClassName($document->getNsid(), $defName);
212
-
209
+
// Create a temporary document for this specific definition
213
210
$definition = $document->getDefinition($defName);
214
-
$properties = $this->extractProperties($definition, $document);
215
211
216
-
$code = $this->templateRenderer->render('object', [
217
-
'namespace' => $namespace,
218
-
'className' => $className,
212
+
// Build a temporary lexicon document for the object definition
213
+
$objectNsid = $document->getNsid().'.'.$defName;
214
+
$tempSchema = [
215
+
'id' => $objectNsid,
216
+
'lexicon' => 1,
219
217
'description' => $definition['description'] ?? null,
220
-
'properties' => $properties,
221
-
]);
218
+
'defs' => [
219
+
'main' => [
220
+
'type' => 'object',
221
+
'properties' => $definition['properties'] ?? [],
222
+
'required' => $definition['required'] ?? [],
223
+
'description' => $definition['description'] ?? null,
224
+
],
225
+
],
226
+
];
227
+
228
+
$tempDocument = \SocialDept\Schema\Data\LexiconDocument::fromArray($tempSchema);
222
229
230
+
// Use ClassGenerator for proper code generation
231
+
$code = $this->classGenerator->generate($tempDocument);
232
+
233
+
$naming = $this->classGenerator->getNaming();
234
+
$namespace = $naming->nsidToNamespace($objectNsid);
235
+
$className = $naming->toClassName($defName);
223
236
$filePath = $this->getFilePath($namespace, $className);
224
237
225
238
if (! ($options['dryRun'] ?? false)) {
···
301
314
*/
302
315
public function getMetadata(string $nsid): array
303
316
{
304
-
$schema = $this->schemaLoader->load($nsid);
305
-
$document = LexiconDocument::fromArray($schema);
317
+
$document = $this->schemaLoader->load($nsid);
306
318
307
319
$namespace = $this->namespaceResolver->resolveNamespace($document->getNsid());
308
320
$className = $this->namespaceResolver->resolveClassName($document->getNsid());
+1
-1
src/Generator/DocBlockGenerator.php
+1
-1
src/Generator/DocBlockGenerator.php
+157
-14
src/Generator/MethodGenerator.php
+157
-14
src/Generator/MethodGenerator.php
···
3
3
namespace SocialDept\Schema\Generator;
4
4
5
5
use SocialDept\Schema\Data\LexiconDocument;
6
+
use SocialDept\Schema\Support\ExtensionManager;
6
7
7
8
class MethodGenerator
8
9
{
···
27
28
protected ModelMapper $modelMapper;
28
29
29
30
/**
31
+
* Extension manager instance.
32
+
*/
33
+
protected ExtensionManager $extensions;
34
+
35
+
/**
30
36
* Create a new MethodGenerator.
31
37
*/
32
38
public function __construct(
33
39
?NamingConverter $naming = null,
34
40
?TypeMapper $typeMapper = null,
35
41
?StubRenderer $renderer = null,
36
-
?ModelMapper $modelMapper = null
42
+
?ModelMapper $modelMapper = null,
43
+
?ExtensionManager $extensions = null
37
44
) {
38
-
$this->naming = $naming ?? new NamingConverter();
45
+
$this->naming = $naming ?? new NamingConverter;
39
46
$this->typeMapper = $typeMapper ?? new TypeMapper($this->naming);
40
-
$this->renderer = $renderer ?? new StubRenderer();
47
+
$this->renderer = $renderer ?? new StubRenderer;
41
48
$this->modelMapper = $modelMapper ?? new ModelMapper($this->naming, $this->typeMapper);
49
+
$this->extensions = $extensions ?? new ExtensionManager;
42
50
}
43
51
44
52
/**
···
61
69
{
62
70
$nsid = $document->getNsid();
63
71
64
-
return $this->renderer->render('method', [
72
+
$method = $this->renderer->render('method', [
65
73
'docBlock' => $this->generateDocBlock('Get the lexicon NSID for this data type.', 'string'),
66
74
'visibility' => 'public ',
67
75
'static' => 'static ',
···
70
78
'returnType' => ': string',
71
79
'body' => " return '{$nsid}';",
72
80
]);
81
+
82
+
return $this->extensions->filter('filter:method:getLexicon', $method, $document);
73
83
}
74
84
75
85
/**
···
78
88
public function generateFromArray(LexiconDocument $document): string
79
89
{
80
90
$mainDef = $document->getMainDefinition();
81
-
$properties = $mainDef['properties'] ?? [];
82
-
$required = $mainDef['required'] ?? [];
91
+
92
+
// For record types, properties are nested under 'record'
93
+
$type = $mainDef['type'] ?? null;
94
+
if ($type === 'record') {
95
+
$recordDef = $mainDef['record'] ?? [];
96
+
$properties = $recordDef['properties'] ?? [];
97
+
$required = $recordDef['required'] ?? [];
98
+
} else {
99
+
// For object types, properties are at the top level
100
+
$properties = $mainDef['properties'] ?? [];
101
+
$required = $mainDef['required'] ?? [];
102
+
}
83
103
84
104
if (empty($properties)) {
85
105
return $this->generateEmptyFromArray();
···
88
108
$assignments = $this->generateFromArrayAssignments($properties, $required);
89
109
$body = " return new static(\n".$assignments."\n );";
90
110
91
-
return $this->renderer->render('method', [
111
+
$method = $this->renderer->render('method', [
92
112
'docBlock' => $this->generateDocBlock('Create an instance from an array.', 'static', [
93
113
['name' => 'data', 'type' => 'array', 'description' => 'The data array'],
94
114
]),
···
99
119
'returnType' => ': static',
100
120
'body' => $body,
101
121
]);
122
+
123
+
return $this->extensions->filter('filter:method:fromArray', $method, $document, $properties, $required);
102
124
}
103
125
104
126
/**
···
129
151
{
130
152
$lines = [];
131
153
154
+
// Generate required parameters first
132
155
foreach ($properties as $name => $definition) {
133
-
$type = $definition['type'] ?? 'unknown';
134
-
$assignment = $this->generatePropertyAssignment($name, $definition, $type, $required);
135
-
$lines[] = ' '.$name.': '.$assignment.',';
156
+
if (in_array($name, $required)) {
157
+
$type = $definition['type'] ?? 'unknown';
158
+
$assignment = $this->generatePropertyAssignment($name, $definition, $type, $required);
159
+
$lines[] = ' '.$name.': '.$assignment.',';
160
+
}
161
+
}
162
+
163
+
// Then generate optional parameters
164
+
foreach ($properties as $name => $definition) {
165
+
if (! in_array($name, $required)) {
166
+
$type = $definition['type'] ?? 'unknown';
167
+
$assignment = $this->generatePropertyAssignment($name, $definition, $type, $required);
168
+
$lines[] = ' '.$name.': '.$assignment.',';
169
+
}
136
170
}
137
171
138
172
// Remove trailing comma from last line
···
152
186
protected function generatePropertyAssignment(string $name, array $definition, string $type, array $required): string
153
187
{
154
188
$isRequired = in_array($name, $required);
189
+
$assignment = $this->generatePropertyAssignmentInternal($name, $definition, $type, $required);
190
+
191
+
return $this->extensions->filter('filter:method:propertyAssignment', $assignment, $name, $definition, $type, $required);
192
+
}
193
+
194
+
/**
195
+
* Internal property assignment generation logic.
196
+
*
197
+
* @param array<string, mixed> $definition
198
+
* @param array<string> $required
199
+
*/
200
+
protected function generatePropertyAssignmentInternal(string $name, array $definition, string $type, array $required): string
201
+
{
202
+
$isRequired = in_array($name, $required);
155
203
156
204
// Handle reference types
157
205
if ($type === 'ref' && isset($definition['ref'])) {
158
-
$refClass = $this->naming->nsidToClassName($definition['ref']);
206
+
$ref = $definition['ref'];
207
+
208
+
// Skip local references (starting with #) - treat as mixed
209
+
if (str_starts_with($ref, '#')) {
210
+
// Local references don't need conversion, just return the data
211
+
if ($isRequired) {
212
+
return "\$data['{$name}']";
213
+
}
214
+
215
+
return "\$data['{$name}'] ?? null";
216
+
}
217
+
218
+
// Handle NSID fragments - extract just the NSID part
219
+
if (str_contains($ref, '#')) {
220
+
$ref = explode('#', $ref)[0];
221
+
}
222
+
223
+
$refClass = $this->naming->nsidToClassName($ref);
159
224
$className = basename(str_replace('\\', '/', $refClass));
160
225
161
226
if ($isRequired) {
···
167
232
168
233
// Handle arrays of references
169
234
if ($type === 'array' && isset($definition['items']['type']) && $definition['items']['type'] === 'ref') {
170
-
$refClass = $this->naming->nsidToClassName($definition['items']['ref']);
235
+
$ref = $definition['items']['ref'];
236
+
237
+
// Skip local references - treat array as mixed
238
+
if (str_starts_with($ref, '#')) {
239
+
return "\$data['{$name}'] ?? []";
240
+
}
241
+
242
+
// Handle NSID fragments
243
+
if (str_contains($ref, '#')) {
244
+
$ref = explode('#', $ref)[0];
245
+
}
246
+
247
+
$refClass = $this->naming->nsidToClassName($ref);
171
248
$className = basename(str_replace('\\', '/', $refClass));
172
249
173
250
return "isset(\$data['{$name}']) ? array_map(fn (\$item) => {$className}::fromArray(\$item), \$data['{$name}']) : []";
···
181
258
// Handle DateTime types (if string format matches ISO8601)
182
259
if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') {
183
260
if ($isRequired) {
184
-
return "new \\DateTime(\$data['{$name}'])";
261
+
return "Carbon::parse(\$data['{$name}'])";
262
+
}
263
+
264
+
return "isset(\$data['{$name}']) ? Carbon::parse(\$data['{$name}']) : null";
265
+
}
266
+
267
+
// Handle union types with refs
268
+
if ($type === 'union' && isset($definition['refs']) && is_array($definition['refs'])) {
269
+
$refs = $definition['refs'];
270
+
$isClosed = $definition['closed'] ?? false;
271
+
272
+
// Filter out local references
273
+
$externalRefs = array_values(array_filter($refs, fn ($ref) => ! str_starts_with($ref, '#')));
274
+
275
+
// Handle closed unions - use UnionHelper for discrimination
276
+
if ($isClosed && ! empty($externalRefs)) {
277
+
// Build array of variant class names
278
+
$variantClasses = [];
279
+
foreach ($externalRefs as $ref) {
280
+
// Handle NSID fragments
281
+
if (str_contains($ref, '#')) {
282
+
$ref = explode('#', $ref)[0];
283
+
}
284
+
285
+
$refClass = $this->naming->nsidToClassName($ref);
286
+
$className = basename(str_replace('\\', '/', $refClass));
287
+
$variantClasses[] = "{$className}::class";
288
+
}
289
+
290
+
$variantsArray = '['.implode(', ', $variantClasses).']';
291
+
292
+
if ($isRequired) {
293
+
return "\\SocialDept\\Schema\\Support\\UnionHelper::resolveClosedUnion(\$data['{$name}'], {$variantsArray})";
294
+
}
295
+
296
+
return "isset(\$data['{$name}']) ? \\SocialDept\\Schema\\Support\\UnionHelper::resolveClosedUnion(\$data['{$name}'], {$variantsArray}) : null";
185
297
}
186
298
187
-
return "isset(\$data['{$name}']) ? new \\DateTime(\$data['{$name}']) : null";
299
+
// Open unions - validate $type presence using UnionHelper
300
+
if (! $isClosed) {
301
+
if ($isRequired) {
302
+
return "\\SocialDept\\Schema\\Support\\UnionHelper::validateOpenUnion(\$data['{$name}'])";
303
+
}
304
+
305
+
return "isset(\$data['{$name}']) ? \\SocialDept\\Schema\\Support\\UnionHelper::validateOpenUnion(\$data['{$name}']) : null";
306
+
}
307
+
308
+
// Fallback for unions with only local refs
309
+
if ($isRequired) {
310
+
return "\$data['{$name}']";
311
+
}
312
+
313
+
return "\$data['{$name}'] ?? null";
314
+
}
315
+
316
+
// Handle blob types (already converted to BlobReference by the protocol)
317
+
if ($type === 'blob') {
318
+
if ($isRequired) {
319
+
return "\$data['{$name}']";
320
+
}
321
+
322
+
return "\$data['{$name}'] ?? null";
188
323
}
189
324
190
325
// Default: simple property access
···
333
468
public function getModelMapper(): ModelMapper
334
469
{
335
470
return $this->modelMapper;
471
+
}
472
+
473
+
/**
474
+
* Get the extension manager.
475
+
*/
476
+
public function getExtensions(): ExtensionManager
477
+
{
478
+
return $this->extensions;
336
479
}
337
480
}
+1
-2
src/Generator/ModelMapper.php
+1
-2
src/Generator/ModelMapper.php
···
19
19
*/
20
20
public function __construct(?NamingConverter $naming = null, ?TypeMapper $typeMapper = null)
21
21
{
22
-
$this->naming = $naming ?? new NamingConverter();
22
+
$this->naming = $naming ?? new NamingConverter;
23
23
$this->typeMapper = $typeMapper ?? new TypeMapper($this->naming);
24
24
}
25
25
···
27
27
* Generate toModel method body.
28
28
*
29
29
* @param array<string, array<string, mixed>> $properties
30
-
* @param string $modelClass
31
30
*/
32
31
public function generateToModelBody(array $properties, string $modelClass = 'Model'): string
33
32
{
+2
-2
src/Generator/NamingConverter.php
+2
-2
src/Generator/NamingConverter.php
···
38
38
{
39
39
$nsid = Nsid::parse($nsidString);
40
40
41
-
// Split authority into parts (e.g., "app.bsky" -> ["app", "bsky"])
42
-
$authorityParts = array_reverse(explode('.', $nsid->getAuthority()));
41
+
// Split authority into parts (e.g., "blog.pckt" -> ["blog", "pckt"])
42
+
$authorityParts = explode('.', $nsid->getAuthority());
43
43
44
44
// Convert each part to PascalCase
45
45
$namespaceParts = array_map(
+2
-2
src/Generator/PropertyGenerator.php
+2
-2
src/Generator/PropertyGenerator.php
···
19
19
*/
20
20
public function __construct(?TypeMapper $typeMapper = null, ?StubRenderer $renderer = null)
21
21
{
22
-
$this->typeMapper = $typeMapper ?? new TypeMapper();
23
-
$this->renderer = $renderer ?? new StubRenderer();
22
+
$this->typeMapper = $typeMapper ?? new TypeMapper;
23
+
$this->renderer = $renderer ?? new StubRenderer;
24
24
}
25
25
26
26
/**
+1
-1
src/Generator/TemplateRenderer.php
+1
-1
src/Generator/TemplateRenderer.php
+190
-24
src/Generator/TypeMapper.php
+190
-24
src/Generator/TypeMapper.php
···
2
2
3
3
namespace SocialDept\Schema\Generator;
4
4
5
+
use SocialDept\Schema\Support\ExtensionManager;
6
+
5
7
class TypeMapper
6
8
{
7
9
/**
8
10
* Naming converter instance.
9
11
*/
10
12
protected NamingConverter $naming;
13
+
14
+
/**
15
+
* Local definition map for resolving #refs.
16
+
*
17
+
* @var array<string, string>
18
+
*/
19
+
protected array $localDefinitions = [];
20
+
21
+
/**
22
+
* Extension manager instance.
23
+
*/
24
+
protected ExtensionManager $extensions;
11
25
12
26
/**
13
27
* Create a new TypeMapper.
14
28
*/
15
-
public function __construct(?NamingConverter $naming = null)
29
+
public function __construct(?NamingConverter $naming = null, ?ExtensionManager $extensions = null)
16
30
{
17
-
$this->naming = $naming ?? new NamingConverter();
31
+
$this->naming = $naming ?? new NamingConverter;
32
+
$this->extensions = $extensions ?? new ExtensionManager;
33
+
}
34
+
35
+
/**
36
+
* Set local definitions for resolving local references.
37
+
*
38
+
* @param array<string, string> $localDefinitions Map of #ref => class name
39
+
*/
40
+
public function setLocalDefinitions(array $localDefinitions): void
41
+
{
42
+
$this->localDefinitions = $localDefinitions;
18
43
}
19
44
20
45
/**
···
27
52
$type = $definition['type'] ?? 'unknown';
28
53
29
54
$phpType = match ($type) {
30
-
'string' => 'string',
55
+
'string' => $this->mapStringType($definition),
31
56
'integer' => 'int',
32
57
'boolean' => 'bool',
33
58
'number' => 'float',
34
59
'array' => $this->mapArrayType($definition),
35
60
'object' => $this->mapObjectType($definition),
36
-
'blob' => '\\SocialDept\\Schema\\Data\\BlobReference',
61
+
'blob' => 'BlobReference',
37
62
'bytes' => 'string',
38
63
'cid-link' => 'string',
39
64
'unknown' => 'mixed',
···
43
68
};
44
69
45
70
if ($nullable && $phpType !== 'mixed') {
46
-
return '?'.$phpType;
71
+
$phpType = '?'.$phpType;
47
72
}
48
73
49
-
return $phpType;
74
+
return $this->extensions->filter('filter:type:phpType', $phpType, $definition, $nullable);
50
75
}
51
76
52
77
/**
···
59
84
$type = $definition['type'] ?? 'unknown';
60
85
61
86
$docType = match ($type) {
62
-
'string' => 'string',
87
+
'string' => $this->mapStringType($definition),
63
88
'integer' => 'int',
64
89
'boolean' => 'bool',
65
90
'number' => 'float',
66
91
'array' => $this->mapArrayDocType($definition),
67
92
'object' => $this->mapObjectDocType($definition),
68
-
'blob' => '\\SocialDept\\Schema\\Data\\BlobReference',
93
+
'blob' => 'BlobReference',
69
94
'bytes' => 'string',
70
95
'cid-link' => 'string',
71
96
'unknown' => 'mixed',
···
75
100
};
76
101
77
102
if ($nullable && $docType !== 'mixed') {
78
-
return $docType.'|null';
103
+
$docType = $docType.'|null';
104
+
}
105
+
106
+
return $this->extensions->filter('filter:type:phpDocType', $docType, $definition, $nullable);
107
+
}
108
+
109
+
/**
110
+
* Map string type.
111
+
*
112
+
* @param array<string, mixed> $definition
113
+
*/
114
+
protected function mapStringType(array $definition): string
115
+
{
116
+
// Check for datetime format
117
+
if (isset($definition['format']) && $definition['format'] === 'datetime') {
118
+
return 'Carbon';
79
119
}
80
120
81
-
return $docType;
121
+
return 'string';
82
122
}
83
123
84
124
/**
···
103
143
}
104
144
105
145
$itemType = $this->toPhpDocType($definition['items']);
146
+
147
+
// array<mixed> is redundant, just use array
148
+
if ($itemType === 'mixed') {
149
+
return 'array';
150
+
}
106
151
107
152
return "array<{$itemType}>";
108
153
}
···
153
198
return 'mixed';
154
199
}
155
200
156
-
// Convert NSID reference to class name
157
-
return '\\'.$this->naming->nsidToClassName($definition['ref']);
201
+
$ref = $definition['ref'];
202
+
203
+
// Resolve local references using the local definitions map
204
+
if (str_starts_with($ref, '#')) {
205
+
return $this->localDefinitions[$ref] ?? 'mixed';
206
+
}
207
+
208
+
// Handle NSID fragments (e.g., com.atproto.label.defs#selfLabels)
209
+
// Extract just the NSID part for class resolution
210
+
if (str_contains($ref, '#')) {
211
+
$ref = explode('#', $ref)[0];
212
+
}
213
+
214
+
// Convert NSID reference to fully qualified class name
215
+
$fqcn = $this->naming->nsidToClassName($ref);
216
+
217
+
// Extract short class name (last part after final backslash)
218
+
$parts = explode('\\', $fqcn);
219
+
220
+
return end($parts);
158
221
}
159
222
160
223
/**
···
174
237
*/
175
238
protected function mapUnionType(array $definition): string
176
239
{
177
-
// For runtime type hints, unions of different types must be 'mixed'
178
-
return 'mixed';
240
+
// Open unions (closed=false or not set) should always be mixed
241
+
// because future schema versions could add more types
242
+
$isClosed = $definition['closed'] ?? false;
243
+
244
+
if (! $isClosed) {
245
+
return 'mixed';
246
+
}
247
+
248
+
// For closed unions, extract external refs
249
+
$refs = $definition['refs'] ?? [];
250
+
$externalRefs = array_values(array_filter($refs, fn ($ref) => ! str_starts_with($ref, '#')));
251
+
252
+
if (empty($externalRefs)) {
253
+
return 'mixed';
254
+
}
255
+
256
+
// Build union type with all variants
257
+
$types = [];
258
+
foreach ($externalRefs as $ref) {
259
+
// Handle NSID fragments - extract just the NSID part
260
+
if (str_contains($ref, '#')) {
261
+
$ref = explode('#', $ref)[0];
262
+
}
263
+
264
+
// Convert to fully qualified class name, then extract short name
265
+
$fqcn = $this->naming->nsidToClassName($ref);
266
+
$parts = explode('\\', $fqcn);
267
+
$types[] = end($parts);
268
+
}
269
+
270
+
// Return union type (e.g., "Theme|ThemeV2" or just "Theme" for single ref)
271
+
return implode('|', $types);
179
272
}
180
273
181
274
/**
···
189
282
return 'mixed';
190
283
}
191
284
192
-
$types = array_map(
193
-
fn ($ref) => '\\'.$this->naming->nsidToClassName($ref),
194
-
$definition['refs']
195
-
);
285
+
// Open unions should be typed as mixed since future types could be added
286
+
$isClosed = $definition['closed'] ?? false;
287
+
if (! $isClosed) {
288
+
return 'mixed';
289
+
}
290
+
291
+
// For closed unions, list all the specific types
292
+
$types = [];
293
+
foreach ($definition['refs'] as $ref) {
294
+
// Resolve local references using the local definitions map
295
+
if (str_starts_with($ref, '#')) {
296
+
$types[] = $this->localDefinitions[$ref] ?? 'mixed';
297
+
298
+
continue;
299
+
}
300
+
301
+
// Handle NSID fragments - extract just the NSID part
302
+
if (str_contains($ref, '#')) {
303
+
$ref = explode('#', $ref)[0];
304
+
}
305
+
306
+
// Convert to fully qualified class name, then extract short name
307
+
$fqcn = $this->naming->nsidToClassName($ref);
308
+
$parts = explode('\\', $fqcn);
309
+
$types[] = end($parts);
310
+
}
196
311
197
312
return implode('|', $types);
198
313
}
···
263
378
{
264
379
$type = $definition['type'] ?? 'unknown';
265
380
381
+
// Check for datetime format on strings
382
+
if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') {
383
+
return true;
384
+
}
385
+
266
386
return in_array($type, ['ref', 'blob']);
267
387
}
268
388
···
276
396
{
277
397
$type = $definition['type'] ?? 'unknown';
278
398
399
+
if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') {
400
+
return ['Carbon\\Carbon'];
401
+
}
402
+
279
403
if ($type === 'blob') {
280
404
return ['SocialDept\\Schema\\Data\\BlobReference'];
281
405
}
282
406
283
407
if ($type === 'ref' && isset($definition['ref'])) {
284
-
return [$this->naming->nsidToClassName($definition['ref'])];
408
+
$ref = $definition['ref'];
409
+
410
+
// Skip local references (starting with #)
411
+
if (str_starts_with($ref, '#')) {
412
+
return [];
413
+
}
414
+
415
+
// Handle NSID fragments - extract just the NSID part
416
+
if (str_contains($ref, '#')) {
417
+
$ref = explode('#', $ref)[0];
418
+
}
419
+
420
+
return [$this->naming->nsidToClassName($ref)];
285
421
}
286
422
287
423
if ($type === 'union' && isset($definition['refs'])) {
288
-
return array_map(
289
-
fn ($ref) => $this->naming->nsidToClassName($ref),
290
-
$definition['refs']
291
-
);
424
+
// Open unions don't need use statements since they're typed as mixed
425
+
$isClosed = $definition['closed'] ?? false;
426
+
if (! $isClosed) {
427
+
return [];
428
+
}
429
+
430
+
// For closed unions, import the referenced classes
431
+
$classes = [];
432
+
433
+
foreach ($definition['refs'] as $ref) {
434
+
// Skip local references
435
+
if (str_starts_with($ref, '#')) {
436
+
continue;
437
+
}
438
+
439
+
// Handle NSID fragments - extract just the NSID part
440
+
if (str_contains($ref, '#')) {
441
+
$ref = explode('#', $ref)[0];
442
+
}
443
+
444
+
$classes[] = $this->naming->nsidToClassName($ref);
445
+
}
446
+
447
+
return $classes;
292
448
}
293
449
294
-
return [];
450
+
$uses = [];
451
+
452
+
return $this->extensions->filter('filter:type:useStatements', $uses, $definition);
453
+
}
454
+
455
+
/**
456
+
* Get the extension manager.
457
+
*/
458
+
public function getExtensions(): ExtensionManager
459
+
{
460
+
return $this->extensions;
295
461
}
296
462
}
+1
-3
stubs/class.stub
+1
-3
stubs/class.stub