+319
src/Generator/ClassGenerator.php
+319
src/Generator/ClassGenerator.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\Schema\Generator;
4
+
5
+
use SocialDept\Schema\Data\LexiconDocument;
6
+
use SocialDept\Schema\Exceptions\GenerationException;
7
+
8
+
class ClassGenerator
9
+
{
10
+
/**
11
+
* Naming converter instance.
12
+
*/
13
+
protected NamingConverter $naming;
14
+
15
+
/**
16
+
* Type mapper instance.
17
+
*/
18
+
protected TypeMapper $typeMapper;
19
+
20
+
/**
21
+
* Stub renderer instance.
22
+
*/
23
+
protected StubRenderer $renderer;
24
+
25
+
/**
26
+
* Create a new ClassGenerator.
27
+
*/
28
+
public function __construct(
29
+
?NamingConverter $naming = null,
30
+
?TypeMapper $typeMapper = null,
31
+
?StubRenderer $renderer = null
32
+
) {
33
+
$this->naming = $naming ?? new NamingConverter;
34
+
$this->typeMapper = $typeMapper ?? new TypeMapper($this->naming);
35
+
$this->renderer = $renderer ?? new StubRenderer;
36
+
}
37
+
38
+
/**
39
+
* Generate a complete PHP class from a lexicon document.
40
+
*/
41
+
public function generate(LexiconDocument $document): string
42
+
{
43
+
$nsid = $document->getNsid();
44
+
$mainDef = $document->getMainDefinition();
45
+
46
+
if ($mainDef === null) {
47
+
throw GenerationException::withContext('No main definition found', ['nsid' => $nsid]);
48
+
}
49
+
50
+
$type = $mainDef['type'] ?? null;
51
+
52
+
if (! in_array($type, ['record', 'object'])) {
53
+
throw GenerationException::withContext(
54
+
'Can only generate classes for record and object types',
55
+
['nsid' => $nsid, 'type' => $type]
56
+
);
57
+
}
58
+
59
+
// Get class components
60
+
$namespace = $this->naming->nsidToNamespace($nsid);
61
+
$className = $this->naming->toClassName($document->id->getName());
62
+
$useStatements = $this->collectUseStatements($mainDef);
63
+
$properties = $this->generateProperties($mainDef);
64
+
$constructor = $this->generateConstructor($mainDef);
65
+
$methods = $this->generateMethods($document);
66
+
$docBlock = $this->generateClassDocBlock($document, $mainDef);
67
+
68
+
// Render the class
69
+
return $this->renderer->render('class', [
70
+
'namespace' => $namespace,
71
+
'imports' => $this->formatUseStatements($useStatements),
72
+
'docBlock' => $docBlock,
73
+
'className' => $className,
74
+
'extends' => ' extends \\SocialDept\\Schema\\Data\\Data',
75
+
'implements' => '',
76
+
'properties' => $properties,
77
+
'constructor' => $constructor,
78
+
'methods' => $methods,
79
+
]);
80
+
}
81
+
82
+
/**
83
+
* Generate class properties.
84
+
*
85
+
* @param array<string, mixed> $definition
86
+
*/
87
+
protected function generateProperties(array $definition): string
88
+
{
89
+
$properties = $definition['properties'] ?? [];
90
+
$required = $definition['required'] ?? [];
91
+
92
+
if (empty($properties)) {
93
+
return '';
94
+
}
95
+
96
+
$lines = [];
97
+
98
+
foreach ($properties as $name => $propDef) {
99
+
$isRequired = in_array($name, $required);
100
+
$phpType = $this->typeMapper->toPhpType($propDef, ! $isRequired);
101
+
$docType = $this->typeMapper->toPhpDocType($propDef, ! $isRequired);
102
+
$description = $propDef['description'] ?? null;
103
+
104
+
// Build property doc comment
105
+
$docLines = [' /**'];
106
+
if ($description) {
107
+
$docLines[] = ' * '.$description;
108
+
$docLines[] = ' *';
109
+
}
110
+
$docLines[] = ' * @var '.$docType;
111
+
$docLines[] = ' */';
112
+
113
+
$lines[] = implode("\n", $docLines);
114
+
$lines[] = ' public readonly '.$phpType.' $'.$name.';';
115
+
$lines[] = '';
116
+
}
117
+
118
+
return rtrim(implode("\n", $lines));
119
+
}
120
+
121
+
/**
122
+
* Generate class constructor.
123
+
*
124
+
* @param array<string, mixed> $definition
125
+
*/
126
+
protected function generateConstructor(array $definition): string
127
+
{
128
+
$properties = $definition['properties'] ?? [];
129
+
$required = $definition['required'] ?? [];
130
+
131
+
if (empty($properties)) {
132
+
return '';
133
+
}
134
+
135
+
$params = [];
136
+
137
+
foreach ($properties as $name => $propDef) {
138
+
$isRequired = in_array($name, $required);
139
+
$phpType = $this->typeMapper->toPhpType($propDef, ! $isRequired);
140
+
$default = ! $isRequired ? ' = null' : '';
141
+
142
+
$params[] = ' public readonly '.$phpType.' $'.$name.$default.',';
143
+
}
144
+
145
+
// Remove trailing comma from last parameter
146
+
if (! empty($params)) {
147
+
$params[count($params) - 1] = rtrim($params[count($params) - 1], ',');
148
+
}
149
+
150
+
return " public function __construct(\n".implode("\n", $params)."\n ) {\n }";
151
+
}
152
+
153
+
/**
154
+
* Generate class methods.
155
+
*/
156
+
protected function generateMethods(LexiconDocument $document): string
157
+
{
158
+
$methods = [];
159
+
160
+
// Generate getLexicon method
161
+
$methods[] = $this->generateGetLexiconMethod($document);
162
+
163
+
// Generate fromArray method
164
+
$methods[] = $this->generateFromArrayMethod($document);
165
+
166
+
return implode("\n\n", $methods);
167
+
}
168
+
169
+
/**
170
+
* Generate getLexicon method.
171
+
*/
172
+
protected function generateGetLexiconMethod(LexiconDocument $document): string
173
+
{
174
+
$nsid = $document->getNsid();
175
+
176
+
return " public static function getLexicon(): string\n".
177
+
" {\n".
178
+
" return '{$nsid}';\n".
179
+
" }";
180
+
}
181
+
182
+
/**
183
+
* Generate fromArray method.
184
+
*/
185
+
protected function generateFromArrayMethod(LexiconDocument $document): string
186
+
{
187
+
$mainDef = $document->getMainDefinition();
188
+
$properties = $mainDef['properties'] ?? [];
189
+
190
+
if (empty($properties)) {
191
+
return " public static function fromArray(array \$data): static\n".
192
+
" {\n".
193
+
" return new static();\n".
194
+
" }";
195
+
}
196
+
197
+
$assignments = [];
198
+
foreach ($properties as $name => $propDef) {
199
+
$type = $propDef['type'] ?? 'unknown';
200
+
201
+
// Handle nested objects/refs
202
+
if ($type === 'ref' && isset($propDef['ref'])) {
203
+
$refClass = $this->naming->nsidToClassName($propDef['ref']);
204
+
$refClassName = basename(str_replace('\\', '/', $refClass));
205
+
$assignments[] = " {$name}: isset(\$data['{$name}']) ? {$refClassName}::fromArray(\$data['{$name}']) : null,";
206
+
} elseif ($type === 'array' && isset($propDef['items']['type']) && $propDef['items']['type'] === 'ref') {
207
+
$refClass = $this->naming->nsidToClassName($propDef['items']['ref']);
208
+
$refClassName = basename(str_replace('\\', '/', $refClass));
209
+
$assignments[] = " {$name}: isset(\$data['{$name}']) ? array_map(fn (\$item) => {$refClassName}::fromArray(\$item), \$data['{$name}']) : [],";
210
+
} else {
211
+
$assignments[] = " {$name}: \$data['{$name}'] ?? null,";
212
+
}
213
+
}
214
+
215
+
return " public static function fromArray(array \$data): static\n".
216
+
" {\n".
217
+
" return new static(\n".
218
+
implode("\n", $assignments)."\n".
219
+
" );\n".
220
+
" }";
221
+
}
222
+
223
+
/**
224
+
* Generate class-level documentation block.
225
+
*
226
+
* @param array<string, mixed> $definition
227
+
*/
228
+
protected function generateClassDocBlock(LexiconDocument $document, array $definition): string
229
+
{
230
+
$lines = ['/**'];
231
+
232
+
if ($document->description) {
233
+
$lines[] = ' * '.$document->description;
234
+
$lines[] = ' *';
235
+
}
236
+
237
+
$lines[] = ' * Lexicon: '.$document->getNsid();
238
+
239
+
if (isset($definition['type'])) {
240
+
$lines[] = ' * Type: '.$definition['type'];
241
+
}
242
+
243
+
$lines[] = ' */';
244
+
245
+
return implode("\n", $lines);
246
+
}
247
+
248
+
/**
249
+
* Collect all use statements needed for the class.
250
+
*
251
+
* @param array<string, mixed> $definition
252
+
* @return array<string>
253
+
*/
254
+
protected function collectUseStatements(array $definition): array
255
+
{
256
+
$uses = ['SocialDept\\Schema\\Data\\Data'];
257
+
$properties = $definition['properties'] ?? [];
258
+
259
+
foreach ($properties as $propDef) {
260
+
$propUses = $this->typeMapper->getUseStatements($propDef);
261
+
$uses = array_merge($uses, $propUses);
262
+
263
+
// Handle array items
264
+
if (isset($propDef['items'])) {
265
+
$itemUses = $this->typeMapper->getUseStatements($propDef['items']);
266
+
$uses = array_merge($uses, $itemUses);
267
+
}
268
+
}
269
+
270
+
// Remove duplicates and sort
271
+
$uses = array_unique($uses);
272
+
sort($uses);
273
+
274
+
return $uses;
275
+
}
276
+
277
+
/**
278
+
* Format use statements for output.
279
+
*
280
+
* @param array<string> $uses
281
+
*/
282
+
protected function formatUseStatements(array $uses): string
283
+
{
284
+
if (empty($uses)) {
285
+
return '';
286
+
}
287
+
288
+
$lines = [];
289
+
foreach ($uses as $use) {
290
+
$lines[] = 'use '.ltrim($use, '\\').';';
291
+
}
292
+
293
+
return implode("\n", $lines);
294
+
}
295
+
296
+
/**
297
+
* Get the naming converter.
298
+
*/
299
+
public function getNaming(): NamingConverter
300
+
{
301
+
return $this->naming;
302
+
}
303
+
304
+
/**
305
+
* Get the type mapper.
306
+
*/
307
+
public function getTypeMapper(): TypeMapper
308
+
{
309
+
return $this->typeMapper;
310
+
}
311
+
312
+
/**
313
+
* Get the stub renderer.
314
+
*/
315
+
public function getRenderer(): StubRenderer
316
+
{
317
+
return $this->renderer;
318
+
}
319
+
}
+320
tests/Unit/Generator/ClassGeneratorTest.php
+320
tests/Unit/Generator/ClassGeneratorTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\Schema\Tests\Unit\Generator;
4
+
5
+
use Orchestra\Testbench\TestCase;
6
+
use SocialDept\Schema\Data\LexiconDocument;
7
+
use SocialDept\Schema\Exceptions\GenerationException;
8
+
use SocialDept\Schema\Generator\ClassGenerator;
9
+
use SocialDept\Schema\Parser\Nsid;
10
+
11
+
class ClassGeneratorTest extends TestCase
12
+
{
13
+
protected ClassGenerator $generator;
14
+
15
+
protected function setUp(): void
16
+
{
17
+
parent::setUp();
18
+
19
+
$this->generator = new ClassGenerator;
20
+
}
21
+
22
+
public function test_it_generates_simple_record_class(): void
23
+
{
24
+
$document = $this->createDocument('app.test.post', [
25
+
'type' => 'record',
26
+
'properties' => [
27
+
'text' => ['type' => 'string'],
28
+
'createdAt' => ['type' => 'string'],
29
+
],
30
+
'required' => ['text', 'createdAt'],
31
+
]);
32
+
33
+
$code = $this->generator->generate($document);
34
+
35
+
$this->assertStringContainsString('namespace App\\Lexicon\\Test\\App;', $code);
36
+
$this->assertStringContainsString('class Post extends \\SocialDept\\Schema\\Data\\Data', $code);
37
+
$this->assertStringContainsString('public readonly string $text;', $code);
38
+
$this->assertStringContainsString('public readonly string $createdAt;', $code);
39
+
$this->assertStringContainsString('public static function getLexicon(): string', $code);
40
+
$this->assertStringContainsString("return 'app.test.post';", $code);
41
+
}
42
+
43
+
public function test_it_handles_optional_properties(): void
44
+
{
45
+
$document = $this->createDocument('app.test.post', [
46
+
'type' => 'record',
47
+
'properties' => [
48
+
'title' => ['type' => 'string'],
49
+
'subtitle' => ['type' => 'string'],
50
+
],
51
+
'required' => ['title'],
52
+
]);
53
+
54
+
$code = $this->generator->generate($document);
55
+
56
+
$this->assertStringContainsString('public readonly string $title', $code);
57
+
$this->assertStringContainsString('public readonly ?string $subtitle', $code);
58
+
}
59
+
60
+
public function test_it_generates_constructor_with_parameters(): void
61
+
{
62
+
$document = $this->createDocument('app.test.user', [
63
+
'type' => 'record',
64
+
'properties' => [
65
+
'name' => ['type' => 'string'],
66
+
'age' => ['type' => 'integer'],
67
+
],
68
+
'required' => ['name'],
69
+
]);
70
+
71
+
$code = $this->generator->generate($document);
72
+
73
+
$this->assertStringContainsString('public function __construct(', $code);
74
+
$this->assertStringContainsString('public readonly string $name', $code);
75
+
$this->assertStringContainsString('public readonly ?int $age = null', $code);
76
+
}
77
+
78
+
public function test_it_generates_from_array_method(): void
79
+
{
80
+
$document = $this->createDocument('app.test.post', [
81
+
'type' => 'record',
82
+
'properties' => [
83
+
'text' => ['type' => 'string'],
84
+
],
85
+
'required' => ['text'],
86
+
]);
87
+
88
+
$code = $this->generator->generate($document);
89
+
90
+
$this->assertStringContainsString('public static function fromArray(array $data): static', $code);
91
+
$this->assertStringContainsString('return new static(', $code);
92
+
$this->assertStringContainsString("text: \$data['text'] ?? null", $code);
93
+
}
94
+
95
+
public function test_it_includes_use_statements(): void
96
+
{
97
+
$document = $this->createDocument('app.test.post', [
98
+
'type' => 'record',
99
+
'properties' => [
100
+
'text' => ['type' => 'string'],
101
+
],
102
+
'required' => ['text'],
103
+
]);
104
+
105
+
$code = $this->generator->generate($document);
106
+
107
+
$this->assertStringContainsString('use SocialDept\\Schema\\Data\\Data;', $code);
108
+
}
109
+
110
+
public function test_it_includes_ref_use_statements(): void
111
+
{
112
+
$document = $this->createDocument('app.test.post', [
113
+
'type' => 'record',
114
+
'properties' => [
115
+
'author' => [
116
+
'type' => 'ref',
117
+
'ref' => 'app.test.author',
118
+
],
119
+
],
120
+
'required' => ['author'],
121
+
]);
122
+
123
+
$code = $this->generator->generate($document);
124
+
125
+
$this->assertStringContainsString('use App\\Lexicon\\Test\\App\\Author;', $code);
126
+
}
127
+
128
+
public function test_it_includes_blob_use_statements(): void
129
+
{
130
+
$document = $this->createDocument('app.test.post', [
131
+
'type' => 'record',
132
+
'properties' => [
133
+
'image' => ['type' => 'blob'],
134
+
],
135
+
'required' => [],
136
+
]);
137
+
138
+
$code = $this->generator->generate($document);
139
+
140
+
$this->assertStringContainsString('use SocialDept\\Schema\\Data\\BlobReference;', $code);
141
+
}
142
+
143
+
public function test_it_generates_class_docblock(): void
144
+
{
145
+
$document = $this->createDocument('app.test.post', [
146
+
'type' => 'record',
147
+
'description' => 'A social media post',
148
+
'properties' => ['text' => ['type' => 'string']],
149
+
'required' => ['text'],
150
+
], 'A social media post');
151
+
152
+
$code = $this->generator->generate($document);
153
+
154
+
$this->assertStringContainsString('/**', $code);
155
+
$this->assertStringContainsString('* A social media post', $code);
156
+
$this->assertStringContainsString('* Lexicon: app.test.post', $code);
157
+
$this->assertStringContainsString('* Type: record', $code);
158
+
}
159
+
160
+
public function test_it_generates_property_docblocks(): void
161
+
{
162
+
$document = $this->createDocument('app.test.post', [
163
+
'type' => 'record',
164
+
'properties' => [
165
+
'text' => [
166
+
'type' => 'string',
167
+
'description' => 'The post content',
168
+
],
169
+
],
170
+
'required' => ['text'],
171
+
]);
172
+
173
+
$code = $this->generator->generate($document);
174
+
175
+
$this->assertStringContainsString('* The post content', $code);
176
+
$this->assertStringContainsString('* @var string', $code);
177
+
}
178
+
179
+
public function test_it_throws_when_no_main_definition(): void
180
+
{
181
+
$document = new LexiconDocument(
182
+
lexicon: 1,
183
+
id: Nsid::parse('app.test.empty'),
184
+
defs: [],
185
+
description: null,
186
+
source: null,
187
+
raw: []
188
+
);
189
+
190
+
$this->expectException(GenerationException::class);
191
+
$this->expectExceptionMessage('No main definition found');
192
+
193
+
$this->generator->generate($document);
194
+
}
195
+
196
+
public function test_it_throws_for_non_record_types(): void
197
+
{
198
+
$document = $this->createDocument('app.test.query', [
199
+
'type' => 'query',
200
+
]);
201
+
202
+
$this->expectException(GenerationException::class);
203
+
$this->expectExceptionMessage('Can only generate classes for record and object types');
204
+
205
+
$this->generator->generate($document);
206
+
}
207
+
208
+
public function test_it_handles_empty_properties(): void
209
+
{
210
+
$document = $this->createDocument('app.test.empty', [
211
+
'type' => 'record',
212
+
'properties' => [],
213
+
'required' => [],
214
+
]);
215
+
216
+
$code = $this->generator->generate($document);
217
+
218
+
$this->assertStringContainsString('class Empty', $code);
219
+
$this->assertStringContainsString('public static function fromArray(array $data): static', $code);
220
+
$this->assertStringContainsString('return new static();', $code);
221
+
}
222
+
223
+
public function test_it_handles_array_of_refs(): void
224
+
{
225
+
$document = $this->createDocument('app.test.feed', [
226
+
'type' => 'record',
227
+
'properties' => [
228
+
'posts' => [
229
+
'type' => 'array',
230
+
'items' => [
231
+
'type' => 'ref',
232
+
'ref' => 'app.test.post',
233
+
],
234
+
],
235
+
],
236
+
'required' => ['posts'],
237
+
]);
238
+
239
+
$code = $this->generator->generate($document);
240
+
241
+
$this->assertStringContainsString('use App\\Lexicon\\Test\\App\\Post;', $code);
242
+
$this->assertStringContainsString('public readonly array $posts', $code);
243
+
$this->assertStringContainsString('array_map(fn ($item) => Post::fromArray($item)', $code);
244
+
}
245
+
246
+
public function test_it_generates_object_type(): void
247
+
{
248
+
$document = $this->createDocument('app.test.config', [
249
+
'type' => 'object',
250
+
'properties' => [
251
+
'enabled' => ['type' => 'boolean'],
252
+
],
253
+
'required' => ['enabled'],
254
+
]);
255
+
256
+
$code = $this->generator->generate($document);
257
+
258
+
$this->assertStringContainsString('class Config', $code);
259
+
$this->assertStringContainsString('public readonly bool $enabled', $code);
260
+
}
261
+
262
+
public function test_it_sorts_use_statements(): void
263
+
{
264
+
$document = $this->createDocument('app.test.complex', [
265
+
'type' => 'record',
266
+
'properties' => [
267
+
'image' => ['type' => 'blob'],
268
+
'author' => [
269
+
'type' => 'ref',
270
+
'ref' => 'app.test.author',
271
+
],
272
+
],
273
+
'required' => [],
274
+
]);
275
+
276
+
$code = $this->generator->generate($document);
277
+
278
+
// Use statements should be sorted
279
+
$dataPos = strpos($code, 'use App\\Lexicon\\Test\\App\\Author;');
280
+
$blobPos = strpos($code, 'use SocialDept\\Schema\\Data\\BlobReference;');
281
+
$basePos = strpos($code, 'use SocialDept\\Schema\\Data\\Data;');
282
+
283
+
$this->assertNotFalse($dataPos);
284
+
$this->assertNotFalse($blobPos);
285
+
$this->assertNotFalse($basePos);
286
+
$this->assertLessThan($blobPos, $dataPos); // App before SocialDept
287
+
}
288
+
289
+
public function test_it_provides_accessor_methods(): void
290
+
{
291
+
$naming = $this->generator->getNaming();
292
+
$typeMapper = $this->generator->getTypeMapper();
293
+
$renderer = $this->generator->getRenderer();
294
+
295
+
$this->assertInstanceOf(\SocialDept\Schema\Generator\NamingConverter::class, $naming);
296
+
$this->assertInstanceOf(\SocialDept\Schema\Generator\TypeMapper::class, $typeMapper);
297
+
$this->assertInstanceOf(\SocialDept\Schema\Generator\StubRenderer::class, $renderer);
298
+
}
299
+
300
+
/**
301
+
* Helper to create a test document.
302
+
*
303
+
* @param array<string, mixed> $mainDef
304
+
*/
305
+
protected function createDocument(string $nsid, array $mainDef, ?string $description = null): LexiconDocument
306
+
{
307
+
return new LexiconDocument(
308
+
lexicon: 1,
309
+
id: Nsid::parse($nsid),
310
+
defs: ['main' => $mainDef],
311
+
description: $description,
312
+
source: null,
313
+
raw: [
314
+
'lexicon' => 1,
315
+
'id' => $nsid,
316
+
'defs' => ['main' => $mainDef],
317
+
]
318
+
);
319
+
}
320
+
}