+171
src/Generator/NamingConverter.php
+171
src/Generator/NamingConverter.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\Schema\Generator;
4
+
5
+
use SocialDept\Schema\Parser\Nsid;
6
+
7
+
class NamingConverter
8
+
{
9
+
/**
10
+
* Base namespace for generated classes.
11
+
*/
12
+
protected string $baseNamespace;
13
+
14
+
/**
15
+
* Create a new NamingConverter.
16
+
*/
17
+
public function __construct(string $baseNamespace = 'App\\Lexicon')
18
+
{
19
+
$this->baseNamespace = rtrim($baseNamespace, '\\');
20
+
}
21
+
22
+
/**
23
+
* Convert NSID to fully qualified class name.
24
+
*/
25
+
public function nsidToClassName(string $nsidString): string
26
+
{
27
+
$nsid = Nsid::parse($nsidString);
28
+
$namespace = $this->nsidToNamespace($nsidString);
29
+
$className = $this->toClassName($nsid->getName());
30
+
31
+
return $namespace.'\\'.$className;
32
+
}
33
+
34
+
/**
35
+
* Convert NSID to namespace.
36
+
*/
37
+
public function nsidToNamespace(string $nsidString): string
38
+
{
39
+
$nsid = Nsid::parse($nsidString);
40
+
41
+
// Split authority into parts (e.g., "app.bsky" -> ["app", "bsky"])
42
+
$authorityParts = array_reverse(explode('.', $nsid->getAuthority()));
43
+
44
+
// Convert each part to PascalCase
45
+
$namespaceParts = array_map(
46
+
fn ($part) => $this->toPascalCase($part),
47
+
$authorityParts
48
+
);
49
+
50
+
return $this->baseNamespace.'\\'.implode('\\', $namespaceParts);
51
+
}
52
+
53
+
/**
54
+
* Get class name from NSID name part.
55
+
*/
56
+
public function toClassName(string $name): string
57
+
{
58
+
// Split on dots (e.g., "feed.post" -> "FeedPost")
59
+
$parts = explode('.', $name);
60
+
61
+
$className = implode('', array_map(
62
+
fn ($part) => $this->toPascalCase($part),
63
+
$parts
64
+
));
65
+
66
+
return $className;
67
+
}
68
+
69
+
/**
70
+
* Convert to PascalCase.
71
+
*/
72
+
public function toPascalCase(string $string): string
73
+
{
74
+
// Split on hyphens, underscores, or existing camelCase boundaries
75
+
$words = preg_split('/[-_\s]+|(?=[A-Z])/', $string);
76
+
77
+
if ($words === false) {
78
+
$words = [$string];
79
+
}
80
+
81
+
// Capitalize first letter of each word
82
+
$words = array_map(fn ($word) => ucfirst(strtolower($word)), $words);
83
+
84
+
return implode('', $words);
85
+
}
86
+
87
+
/**
88
+
* Convert to camelCase.
89
+
*/
90
+
public function toCamelCase(string $string): string
91
+
{
92
+
$pascalCase = $this->toPascalCase($string);
93
+
94
+
return lcfirst($pascalCase);
95
+
}
96
+
97
+
/**
98
+
* Convert to snake_case.
99
+
*/
100
+
public function toSnakeCase(string $string): string
101
+
{
102
+
// Insert underscore before capital letters, except at the start
103
+
$snake = preg_replace('/(?<!^)[A-Z]/', '_$0', $string);
104
+
105
+
if ($snake === null) {
106
+
return strtolower($string);
107
+
}
108
+
109
+
return strtolower($snake);
110
+
}
111
+
112
+
/**
113
+
* Convert to kebab-case.
114
+
*/
115
+
public function toKebabCase(string $string): string
116
+
{
117
+
return str_replace('_', '-', $this->toSnakeCase($string));
118
+
}
119
+
120
+
/**
121
+
* Pluralize a word (simple English rules).
122
+
*/
123
+
public function pluralize(string $word): string
124
+
{
125
+
if (str_ends_with($word, 'y')) {
126
+
return substr($word, 0, -1).'ies';
127
+
}
128
+
129
+
if (str_ends_with($word, 's') || str_ends_with($word, 'x') || str_ends_with($word, 'ch') || str_ends_with($word, 'sh')) {
130
+
return $word.'es';
131
+
}
132
+
133
+
return $word.'s';
134
+
}
135
+
136
+
/**
137
+
* Singularize a word (simple English rules).
138
+
*/
139
+
public function singularize(string $word): string
140
+
{
141
+
if (str_ends_with($word, 'ies')) {
142
+
return substr($word, 0, -3).'y';
143
+
}
144
+
145
+
if (str_ends_with($word, 'ses') || str_ends_with($word, 'xes') || str_ends_with($word, 'ches') || str_ends_with($word, 'shes')) {
146
+
return substr($word, 0, -2);
147
+
}
148
+
149
+
if (str_ends_with($word, 's') && ! str_ends_with($word, 'ss')) {
150
+
return substr($word, 0, -1);
151
+
}
152
+
153
+
return $word;
154
+
}
155
+
156
+
/**
157
+
* Get the base namespace.
158
+
*/
159
+
public function getBaseNamespace(): string
160
+
{
161
+
return $this->baseNamespace;
162
+
}
163
+
164
+
/**
165
+
* Set the base namespace.
166
+
*/
167
+
public function setBaseNamespace(string $namespace): void
168
+
{
169
+
$this->baseNamespace = rtrim($namespace, '\\');
170
+
}
171
+
}
+296
src/Generator/TypeMapper.php
+296
src/Generator/TypeMapper.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\Schema\Generator;
4
+
5
+
class TypeMapper
6
+
{
7
+
/**
8
+
* Naming converter instance.
9
+
*/
10
+
protected NamingConverter $naming;
11
+
12
+
/**
13
+
* Create a new TypeMapper.
14
+
*/
15
+
public function __construct(?NamingConverter $naming = null)
16
+
{
17
+
$this->naming = $naming ?? new NamingConverter;
18
+
}
19
+
20
+
/**
21
+
* Map lexicon type to PHP type.
22
+
*
23
+
* @param array<string, mixed> $definition
24
+
*/
25
+
public function toPhpType(array $definition, bool $nullable = false): string
26
+
{
27
+
$type = $definition['type'] ?? 'unknown';
28
+
29
+
$phpType = match ($type) {
30
+
'string' => 'string',
31
+
'integer' => 'int',
32
+
'boolean' => 'bool',
33
+
'number' => 'float',
34
+
'array' => $this->mapArrayType($definition),
35
+
'object' => $this->mapObjectType($definition),
36
+
'blob' => '\\SocialDept\\Schema\\Data\\BlobReference',
37
+
'bytes' => 'string',
38
+
'cid-link' => 'string',
39
+
'unknown' => 'mixed',
40
+
'ref' => $this->mapRefType($definition),
41
+
'union' => $this->mapUnionType($definition),
42
+
default => 'mixed',
43
+
};
44
+
45
+
if ($nullable && $phpType !== 'mixed') {
46
+
return '?'.$phpType;
47
+
}
48
+
49
+
return $phpType;
50
+
}
51
+
52
+
/**
53
+
* Map lexicon type to PHPDoc type.
54
+
*
55
+
* @param array<string, mixed> $definition
56
+
*/
57
+
public function toPhpDocType(array $definition, bool $nullable = false): string
58
+
{
59
+
$type = $definition['type'] ?? 'unknown';
60
+
61
+
$docType = match ($type) {
62
+
'string' => 'string',
63
+
'integer' => 'int',
64
+
'boolean' => 'bool',
65
+
'number' => 'float',
66
+
'array' => $this->mapArrayDocType($definition),
67
+
'object' => $this->mapObjectDocType($definition),
68
+
'blob' => '\\SocialDept\\Schema\\Data\\BlobReference',
69
+
'bytes' => 'string',
70
+
'cid-link' => 'string',
71
+
'unknown' => 'mixed',
72
+
'ref' => $this->mapRefDocType($definition),
73
+
'union' => $this->mapUnionDocType($definition),
74
+
default => 'mixed',
75
+
};
76
+
77
+
if ($nullable && $docType !== 'mixed') {
78
+
return $docType.'|null';
79
+
}
80
+
81
+
return $docType;
82
+
}
83
+
84
+
/**
85
+
* Map array type.
86
+
*
87
+
* @param array<string, mixed> $definition
88
+
*/
89
+
protected function mapArrayType(array $definition): string
90
+
{
91
+
return 'array';
92
+
}
93
+
94
+
/**
95
+
* Map array type for PHPDoc.
96
+
*
97
+
* @param array<string, mixed> $definition
98
+
*/
99
+
protected function mapArrayDocType(array $definition): string
100
+
{
101
+
if (! isset($definition['items'])) {
102
+
return 'array';
103
+
}
104
+
105
+
$itemType = $this->toPhpDocType($definition['items']);
106
+
107
+
return "array<{$itemType}>";
108
+
}
109
+
110
+
/**
111
+
* Map object type.
112
+
*
113
+
* @param array<string, mixed> $definition
114
+
*/
115
+
protected function mapObjectType(array $definition): string
116
+
{
117
+
return 'array';
118
+
}
119
+
120
+
/**
121
+
* Map object type for PHPDoc.
122
+
*
123
+
* @param array<string, mixed> $definition
124
+
*/
125
+
protected function mapObjectDocType(array $definition): string
126
+
{
127
+
if (! isset($definition['properties'])) {
128
+
return 'array';
129
+
}
130
+
131
+
// Build array shape annotation
132
+
$properties = [];
133
+
foreach ($definition['properties'] as $key => $propDef) {
134
+
$propType = $this->toPhpDocType($propDef);
135
+
$properties[] = "{$key}: {$propType}";
136
+
}
137
+
138
+
if (empty($properties)) {
139
+
return 'array';
140
+
}
141
+
142
+
return 'array{'.implode(', ', $properties).'}';
143
+
}
144
+
145
+
/**
146
+
* Map reference type.
147
+
*
148
+
* @param array<string, mixed> $definition
149
+
*/
150
+
protected function mapRefType(array $definition): string
151
+
{
152
+
if (! isset($definition['ref'])) {
153
+
return 'mixed';
154
+
}
155
+
156
+
// Convert NSID reference to class name
157
+
return '\\'.$this->naming->nsidToClassName($definition['ref']);
158
+
}
159
+
160
+
/**
161
+
* Map reference type for PHPDoc.
162
+
*
163
+
* @param array<string, mixed> $definition
164
+
*/
165
+
protected function mapRefDocType(array $definition): string
166
+
{
167
+
return $this->mapRefType($definition);
168
+
}
169
+
170
+
/**
171
+
* Map union type.
172
+
*
173
+
* @param array<string, mixed> $definition
174
+
*/
175
+
protected function mapUnionType(array $definition): string
176
+
{
177
+
// For runtime type hints, unions of different types must be 'mixed'
178
+
return 'mixed';
179
+
}
180
+
181
+
/**
182
+
* Map union type for PHPDoc.
183
+
*
184
+
* @param array<string, mixed> $definition
185
+
*/
186
+
protected function mapUnionDocType(array $definition): string
187
+
{
188
+
if (! isset($definition['refs'])) {
189
+
return 'mixed';
190
+
}
191
+
192
+
$types = array_map(
193
+
fn ($ref) => '\\'.$this->naming->nsidToClassName($ref),
194
+
$definition['refs']
195
+
);
196
+
197
+
return implode('|', $types);
198
+
}
199
+
200
+
/**
201
+
* Check if type is nullable based on definition.
202
+
*
203
+
* @param array<string, mixed> $definition
204
+
*/
205
+
public function isNullable(array $definition, array $required = []): bool
206
+
{
207
+
// Check if explicitly marked as required
208
+
if (isset($definition['required']) && $definition['required'] === true) {
209
+
return false;
210
+
}
211
+
212
+
// Check if in required array
213
+
if (! empty($required)) {
214
+
return false;
215
+
}
216
+
217
+
// Default to nullable for optional fields
218
+
return true;
219
+
}
220
+
221
+
/**
222
+
* Get default value for a type.
223
+
*
224
+
* @param array<string, mixed> $definition
225
+
*/
226
+
public function getDefaultValue(array $definition): ?string
227
+
{
228
+
if (! array_key_exists('default', $definition)) {
229
+
return null;
230
+
}
231
+
232
+
$default = $definition['default'];
233
+
234
+
if ($default === null) {
235
+
return 'null';
236
+
}
237
+
238
+
if (is_string($default)) {
239
+
return "'".addslashes($default)."'";
240
+
}
241
+
242
+
if (is_bool($default)) {
243
+
return $default ? 'true' : 'false';
244
+
}
245
+
246
+
if (is_numeric($default)) {
247
+
return (string) $default;
248
+
}
249
+
250
+
if (is_array($default)) {
251
+
return '[]';
252
+
}
253
+
254
+
return null;
255
+
}
256
+
257
+
/**
258
+
* Check if type needs use statement.
259
+
*
260
+
* @param array<string, mixed> $definition
261
+
*/
262
+
public function needsUseStatement(array $definition): bool
263
+
{
264
+
$type = $definition['type'] ?? 'unknown';
265
+
266
+
return in_array($type, ['ref', 'blob']);
267
+
}
268
+
269
+
/**
270
+
* Get use statements for type.
271
+
*
272
+
* @param array<string, mixed> $definition
273
+
* @return array<string>
274
+
*/
275
+
public function getUseStatements(array $definition): array
276
+
{
277
+
$type = $definition['type'] ?? 'unknown';
278
+
279
+
if ($type === 'blob') {
280
+
return ['SocialDept\\Schema\\Data\\BlobReference'];
281
+
}
282
+
283
+
if ($type === 'ref' && isset($definition['ref'])) {
284
+
return [$this->naming->nsidToClassName($definition['ref'])];
285
+
}
286
+
287
+
if ($type === 'union' && isset($definition['refs'])) {
288
+
return array_map(
289
+
fn ($ref) => $this->naming->nsidToClassName($ref),
290
+
$definition['refs']
291
+
);
292
+
}
293
+
294
+
return [];
295
+
}
296
+
}
+171
tests/Unit/Generator/NamingConverterTest.php
+171
tests/Unit/Generator/NamingConverterTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\Schema\Tests\Unit\Generator;
4
+
5
+
use Orchestra\Testbench\TestCase;
6
+
use SocialDept\Schema\Generator\NamingConverter;
7
+
8
+
class NamingConverterTest extends TestCase
9
+
{
10
+
protected NamingConverter $converter;
11
+
12
+
protected function setUp(): void
13
+
{
14
+
parent::setUp();
15
+
16
+
$this->converter = new NamingConverter('App\\Lexicon');
17
+
}
18
+
19
+
public function test_it_converts_nsid_to_class_name(): void
20
+
{
21
+
$className = $this->converter->nsidToClassName('app.bsky.feed.post');
22
+
23
+
$this->assertSame('App\\Lexicon\\Feed\\Bsky\\App\\Post', $className);
24
+
}
25
+
26
+
public function test_it_converts_nsid_to_namespace(): void
27
+
{
28
+
$namespace = $this->converter->nsidToNamespace('app.bsky.feed.post');
29
+
30
+
$this->assertSame('App\\Lexicon\\Feed\\Bsky\\App', $namespace);
31
+
}
32
+
33
+
public function test_it_handles_multi_part_names(): void
34
+
{
35
+
$className = $this->converter->toClassName('feed.post');
36
+
37
+
$this->assertSame('FeedPost', $className);
38
+
}
39
+
40
+
public function test_it_handles_single_part_names(): void
41
+
{
42
+
$className = $this->converter->toClassName('post');
43
+
44
+
$this->assertSame('Post', $className);
45
+
}
46
+
47
+
public function test_it_converts_to_pascal_case(): void
48
+
{
49
+
$this->assertSame('HelloWorld', $this->converter->toPascalCase('hello_world'));
50
+
$this->assertSame('HelloWorld', $this->converter->toPascalCase('hello-world'));
51
+
$this->assertSame('HelloWorld', $this->converter->toPascalCase('hello world'));
52
+
$this->assertSame('HelloWorld', $this->converter->toPascalCase('helloWorld'));
53
+
$this->assertSame('Foo', $this->converter->toPascalCase('foo'));
54
+
}
55
+
56
+
public function test_it_converts_to_camel_case(): void
57
+
{
58
+
$this->assertSame('helloWorld', $this->converter->toCamelCase('hello_world'));
59
+
$this->assertSame('helloWorld', $this->converter->toCamelCase('hello-world'));
60
+
$this->assertSame('helloWorld', $this->converter->toCamelCase('hello world'));
61
+
$this->assertSame('helloWorld', $this->converter->toCamelCase('HelloWorld'));
62
+
$this->assertSame('foo', $this->converter->toCamelCase('foo'));
63
+
}
64
+
65
+
public function test_it_converts_to_snake_case(): void
66
+
{
67
+
$this->assertSame('hello_world', $this->converter->toSnakeCase('HelloWorld'));
68
+
$this->assertSame('hello_world', $this->converter->toSnakeCase('helloWorld'));
69
+
$this->assertSame('foo', $this->converter->toSnakeCase('foo'));
70
+
$this->assertSame('foo_bar_baz', $this->converter->toSnakeCase('FooBarBaz'));
71
+
}
72
+
73
+
public function test_it_converts_to_kebab_case(): void
74
+
{
75
+
$this->assertSame('hello-world', $this->converter->toKebabCase('HelloWorld'));
76
+
$this->assertSame('hello-world', $this->converter->toKebabCase('helloWorld'));
77
+
$this->assertSame('foo', $this->converter->toKebabCase('foo'));
78
+
$this->assertSame('foo-bar-baz', $this->converter->toKebabCase('FooBarBaz'));
79
+
}
80
+
81
+
public function test_it_pluralizes_words(): void
82
+
{
83
+
$this->assertSame('posts', $this->converter->pluralize('post'));
84
+
$this->assertSame('categories', $this->converter->pluralize('category'));
85
+
$this->assertSame('boxes', $this->converter->pluralize('box'));
86
+
$this->assertSame('churches', $this->converter->pluralize('church'));
87
+
$this->assertSame('bushes', $this->converter->pluralize('bush'));
88
+
$this->assertSame('postses', $this->converter->pluralize('posts')); // 'posts' ends with 's', gets 'es'
89
+
}
90
+
91
+
public function test_it_singularizes_words(): void
92
+
{
93
+
$this->assertSame('post', $this->converter->singularize('posts'));
94
+
$this->assertSame('category', $this->converter->singularize('categories'));
95
+
$this->assertSame('box', $this->converter->singularize('boxes'));
96
+
$this->assertSame('church', $this->converter->singularize('churches'));
97
+
$this->assertSame('bush', $this->converter->singularize('bushes'));
98
+
$this->assertSame('post', $this->converter->singularize('post')); // Already singular
99
+
}
100
+
101
+
public function test_it_handles_complex_nsid(): void
102
+
{
103
+
$className = $this->converter->nsidToClassName('com.atproto.repo.getRecord');
104
+
105
+
$this->assertSame('App\\Lexicon\\Repo\\Atproto\\Com\\GetRecord', $className);
106
+
}
107
+
108
+
public function test_it_gets_base_namespace(): void
109
+
{
110
+
$namespace = $this->converter->getBaseNamespace();
111
+
112
+
$this->assertSame('App\\Lexicon', $namespace);
113
+
}
114
+
115
+
public function test_it_sets_base_namespace(): void
116
+
{
117
+
$this->converter->setBaseNamespace('Custom\\Namespace');
118
+
119
+
$this->assertSame('Custom\\Namespace', $this->converter->getBaseNamespace());
120
+
}
121
+
122
+
public function test_it_strips_trailing_slash_from_namespace(): void
123
+
{
124
+
$converter = new NamingConverter('App\\Lexicon\\');
125
+
126
+
$this->assertSame('App\\Lexicon', $converter->getBaseNamespace());
127
+
}
128
+
129
+
public function test_it_handles_three_part_authority(): void
130
+
{
131
+
$className = $this->converter->nsidToClassName('com.example.api.getUser');
132
+
133
+
$this->assertSame('App\\Lexicon\\Api\\Example\\Com\\GetUser', $className);
134
+
}
135
+
136
+
public function test_it_handles_hyphens_in_names(): void
137
+
{
138
+
$className = $this->converter->toClassName('my-post');
139
+
140
+
$this->assertSame('MyPost', $className);
141
+
}
142
+
143
+
public function test_it_handles_underscores_in_names(): void
144
+
{
145
+
$className = $this->converter->toClassName('my_post');
146
+
147
+
$this->assertSame('MyPost', $className);
148
+
}
149
+
150
+
public function test_namespace_parts_are_reversed(): void
151
+
{
152
+
// app.bsky.feed should become Feed\Bsky\App (reversed)
153
+
$namespace = $this->converter->nsidToNamespace('app.bsky.feed.post');
154
+
155
+
$this->assertStringContainsString('Feed\\Bsky\\App', $namespace);
156
+
}
157
+
158
+
public function test_it_handles_single_letter_parts(): void
159
+
{
160
+
$pascalCase = $this->converter->toPascalCase('a');
161
+
162
+
$this->assertSame('A', $pascalCase);
163
+
}
164
+
165
+
public function test_it_handles_numbers_in_names(): void
166
+
{
167
+
$className = $this->converter->toClassName('post2');
168
+
169
+
$this->assertSame('Post2', $className);
170
+
}
171
+
}
+289
tests/Unit/Generator/TypeMapperTest.php
+289
tests/Unit/Generator/TypeMapperTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\Schema\Tests\Unit\Generator;
4
+
5
+
use Orchestra\Testbench\TestCase;
6
+
use SocialDept\Schema\Generator\NamingConverter;
7
+
use SocialDept\Schema\Generator\TypeMapper;
8
+
9
+
class TypeMapperTest extends TestCase
10
+
{
11
+
protected TypeMapper $mapper;
12
+
13
+
protected function setUp(): void
14
+
{
15
+
parent::setUp();
16
+
17
+
$naming = new NamingConverter('App\\Lexicon');
18
+
$this->mapper = new TypeMapper($naming);
19
+
}
20
+
21
+
public function test_it_maps_string_type(): void
22
+
{
23
+
$type = $this->mapper->toPhpType(['type' => 'string']);
24
+
25
+
$this->assertSame('string', $type);
26
+
}
27
+
28
+
public function test_it_maps_integer_type(): void
29
+
{
30
+
$type = $this->mapper->toPhpType(['type' => 'integer']);
31
+
32
+
$this->assertSame('int', $type);
33
+
}
34
+
35
+
public function test_it_maps_boolean_type(): void
36
+
{
37
+
$type = $this->mapper->toPhpType(['type' => 'boolean']);
38
+
39
+
$this->assertSame('bool', $type);
40
+
}
41
+
42
+
public function test_it_maps_number_type(): void
43
+
{
44
+
$type = $this->mapper->toPhpType(['type' => 'number']);
45
+
46
+
$this->assertSame('float', $type);
47
+
}
48
+
49
+
public function test_it_maps_array_type(): void
50
+
{
51
+
$type = $this->mapper->toPhpType(['type' => 'array']);
52
+
53
+
$this->assertSame('array', $type);
54
+
}
55
+
56
+
public function test_it_maps_object_type(): void
57
+
{
58
+
$type = $this->mapper->toPhpType(['type' => 'object']);
59
+
60
+
$this->assertSame('array', $type);
61
+
}
62
+
63
+
public function test_it_maps_blob_type(): void
64
+
{
65
+
$type = $this->mapper->toPhpType(['type' => 'blob']);
66
+
67
+
$this->assertSame('\\SocialDept\\Schema\\Data\\BlobReference', $type);
68
+
}
69
+
70
+
public function test_it_maps_bytes_type(): void
71
+
{
72
+
$type = $this->mapper->toPhpType(['type' => 'bytes']);
73
+
74
+
$this->assertSame('string', $type);
75
+
}
76
+
77
+
public function test_it_maps_cid_link_type(): void
78
+
{
79
+
$type = $this->mapper->toPhpType(['type' => 'cid-link']);
80
+
81
+
$this->assertSame('string', $type);
82
+
}
83
+
84
+
public function test_it_maps_unknown_type(): void
85
+
{
86
+
$type = $this->mapper->toPhpType(['type' => 'unknown']);
87
+
88
+
$this->assertSame('mixed', $type);
89
+
}
90
+
91
+
public function test_it_maps_ref_type(): void
92
+
{
93
+
$type = $this->mapper->toPhpType([
94
+
'type' => 'ref',
95
+
'ref' => 'app.bsky.feed.post',
96
+
]);
97
+
98
+
$this->assertSame('\\App\\Lexicon\\Feed\\Bsky\\App\\Post', $type);
99
+
}
100
+
101
+
public function test_it_maps_union_type(): void
102
+
{
103
+
$type = $this->mapper->toPhpType(['type' => 'union']);
104
+
105
+
$this->assertSame('mixed', $type);
106
+
}
107
+
108
+
public function test_it_handles_nullable_types(): void
109
+
{
110
+
$type = $this->mapper->toPhpType(['type' => 'string'], true);
111
+
112
+
$this->assertSame('?string', $type);
113
+
}
114
+
115
+
public function test_it_does_not_make_mixed_nullable(): void
116
+
{
117
+
$type = $this->mapper->toPhpType(['type' => 'unknown'], true);
118
+
119
+
$this->assertSame('mixed', $type);
120
+
}
121
+
122
+
public function test_it_maps_array_doc_type_with_items(): void
123
+
{
124
+
$docType = $this->mapper->toPhpDocType([
125
+
'type' => 'array',
126
+
'items' => ['type' => 'string'],
127
+
]);
128
+
129
+
$this->assertSame('array<string>', $docType);
130
+
}
131
+
132
+
public function test_it_maps_array_doc_type_without_items(): void
133
+
{
134
+
$docType = $this->mapper->toPhpDocType(['type' => 'array']);
135
+
136
+
$this->assertSame('array', $docType);
137
+
}
138
+
139
+
public function test_it_maps_object_doc_type_with_properties(): void
140
+
{
141
+
$docType = $this->mapper->toPhpDocType([
142
+
'type' => 'object',
143
+
'properties' => [
144
+
'name' => ['type' => 'string'],
145
+
'age' => ['type' => 'integer'],
146
+
],
147
+
]);
148
+
149
+
$this->assertSame('array{name: string, age: int}', $docType);
150
+
}
151
+
152
+
public function test_it_maps_object_doc_type_without_properties(): void
153
+
{
154
+
$docType = $this->mapper->toPhpDocType(['type' => 'object']);
155
+
156
+
$this->assertSame('array', $docType);
157
+
}
158
+
159
+
public function test_it_maps_union_doc_type(): void
160
+
{
161
+
$docType = $this->mapper->toPhpDocType([
162
+
'type' => 'union',
163
+
'refs' => [
164
+
'app.bsky.feed.post',
165
+
'app.bsky.feed.repost',
166
+
],
167
+
]);
168
+
169
+
$this->assertSame('\\App\\Lexicon\\Feed\\Bsky\\App\\Post|\\App\\Lexicon\\Feed\\Bsky\\App\\Repost', $docType);
170
+
}
171
+
172
+
public function test_it_adds_null_to_doc_type_when_nullable(): void
173
+
{
174
+
$docType = $this->mapper->toPhpDocType(['type' => 'string'], true);
175
+
176
+
$this->assertSame('string|null', $docType);
177
+
}
178
+
179
+
public function test_it_checks_if_type_is_nullable(): void
180
+
{
181
+
// Field marked as required
182
+
$this->assertFalse($this->mapper->isNullable(['required' => true]));
183
+
184
+
// Field in required array
185
+
$this->assertFalse($this->mapper->isNullable(['name' => 'field'], ['field']));
186
+
187
+
// Optional field
188
+
$this->assertTrue($this->mapper->isNullable([]));
189
+
}
190
+
191
+
public function test_it_gets_string_default_value(): void
192
+
{
193
+
$default = $this->mapper->getDefaultValue(['default' => 'hello']);
194
+
195
+
$this->assertSame("'hello'", $default);
196
+
}
197
+
198
+
public function test_it_gets_boolean_default_value(): void
199
+
{
200
+
$this->assertSame('true', $this->mapper->getDefaultValue(['default' => true]));
201
+
$this->assertSame('false', $this->mapper->getDefaultValue(['default' => false]));
202
+
}
203
+
204
+
public function test_it_gets_numeric_default_value(): void
205
+
{
206
+
$this->assertSame('42', $this->mapper->getDefaultValue(['default' => 42]));
207
+
$this->assertSame('3.14', $this->mapper->getDefaultValue(['default' => 3.14]));
208
+
}
209
+
210
+
public function test_it_gets_array_default_value(): void
211
+
{
212
+
$default = $this->mapper->getDefaultValue(['default' => []]);
213
+
214
+
$this->assertSame('[]', $default);
215
+
}
216
+
217
+
public function test_it_gets_null_default_value(): void
218
+
{
219
+
$default = $this->mapper->getDefaultValue(['default' => null]);
220
+
221
+
$this->assertSame('null', $default);
222
+
}
223
+
224
+
public function test_it_returns_null_when_no_default(): void
225
+
{
226
+
$default = $this->mapper->getDefaultValue([]);
227
+
228
+
$this->assertNull($default);
229
+
}
230
+
231
+
public function test_it_checks_if_type_needs_use_statement(): void
232
+
{
233
+
$this->assertTrue($this->mapper->needsUseStatement(['type' => 'ref']));
234
+
$this->assertTrue($this->mapper->needsUseStatement(['type' => 'blob']));
235
+
$this->assertFalse($this->mapper->needsUseStatement(['type' => 'string']));
236
+
}
237
+
238
+
public function test_it_gets_use_statements_for_blob(): void
239
+
{
240
+
$uses = $this->mapper->getUseStatements(['type' => 'blob']);
241
+
242
+
$this->assertContains('SocialDept\\Schema\\Data\\BlobReference', $uses);
243
+
}
244
+
245
+
public function test_it_gets_use_statements_for_ref(): void
246
+
{
247
+
$uses = $this->mapper->getUseStatements([
248
+
'type' => 'ref',
249
+
'ref' => 'app.bsky.feed.post',
250
+
]);
251
+
252
+
$this->assertContains('App\\Lexicon\\Feed\\Bsky\\App\\Post', $uses);
253
+
}
254
+
255
+
public function test_it_gets_use_statements_for_union(): void
256
+
{
257
+
$uses = $this->mapper->getUseStatements([
258
+
'type' => 'union',
259
+
'refs' => [
260
+
'app.bsky.feed.post',
261
+
'app.bsky.feed.repost',
262
+
],
263
+
]);
264
+
265
+
$this->assertContains('App\\Lexicon\\Feed\\Bsky\\App\\Post', $uses);
266
+
$this->assertContains('App\\Lexicon\\Feed\\Bsky\\App\\Repost', $uses);
267
+
}
268
+
269
+
public function test_it_gets_empty_use_statements_for_primitive(): void
270
+
{
271
+
$uses = $this->mapper->getUseStatements(['type' => 'string']);
272
+
273
+
$this->assertEmpty($uses);
274
+
}
275
+
276
+
public function test_it_escapes_quotes_in_string_defaults(): void
277
+
{
278
+
$default = $this->mapper->getDefaultValue(['default' => "it's great"]);
279
+
280
+
$this->assertSame("'it\\'s great'", $default);
281
+
}
282
+
283
+
public function test_it_handles_missing_type(): void
284
+
{
285
+
$type = $this->mapper->toPhpType([]);
286
+
287
+
$this->assertSame('mixed', $type);
288
+
}
289
+
}