Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add model conversion methods

+629 -1
+66 -1
src/Generator/MethodGenerator.php
··· 22 22 protected StubRenderer $renderer; 23 23 24 24 /** 25 + * Model mapper instance. 26 + */ 27 + protected ModelMapper $modelMapper; 28 + 29 + /** 25 30 * Create a new MethodGenerator. 26 31 */ 27 32 public function __construct( 28 33 ?NamingConverter $naming = null, 29 34 ?TypeMapper $typeMapper = null, 30 - ?StubRenderer $renderer = null 35 + ?StubRenderer $renderer = null, 36 + ?ModelMapper $modelMapper = null 31 37 ) { 32 38 $this->naming = $naming ?? new NamingConverter; 33 39 $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming); 34 40 $this->renderer = $renderer ?? new StubRenderer; 41 + $this->modelMapper = $modelMapper ?? new ModelMapper($this->naming, $this->typeMapper); 35 42 } 36 43 37 44 /** ··· 268 275 } 269 276 270 277 return implode(', ', $formatted); 278 + } 279 + 280 + /** 281 + * Generate toModel method. 282 + * 283 + * @param array<string, array<string, mixed>> $properties 284 + */ 285 + public function generateToModel(array $properties, string $modelClass = 'Model'): string 286 + { 287 + $body = $this->modelMapper->generateToModelBody($properties, $modelClass); 288 + 289 + return $this->renderer->render('method', [ 290 + 'docBlock' => $this->generateDocBlock( 291 + 'Convert to a Laravel model instance.', 292 + $modelClass, 293 + [] 294 + ), 295 + 'visibility' => 'public ', 296 + 'static' => '', 297 + 'name' => 'toModel', 298 + 'parameters' => '', 299 + 'returnType' => ': '.$modelClass, 300 + 'body' => $body, 301 + ]); 302 + } 303 + 304 + /** 305 + * Generate fromModel method. 306 + * 307 + * @param array<string, array<string, mixed>> $properties 308 + */ 309 + public function generateFromModel(array $properties, string $modelClass = 'Model'): string 310 + { 311 + $body = $this->modelMapper->generateFromModelBody($properties); 312 + 313 + return $this->renderer->render('method', [ 314 + 'docBlock' => $this->generateDocBlock( 315 + 'Create an instance from a Laravel model.', 316 + 'static', 317 + [ 318 + ['name' => 'model', 'type' => $modelClass, 'description' => 'The model instance'], 319 + ] 320 + ), 321 + 'visibility' => 'public ', 322 + 'static' => 'static ', 323 + 'name' => 'fromModel', 324 + 'parameters' => $modelClass.' $model', 325 + 'returnType' => ': static', 326 + 'body' => $body, 327 + ]); 328 + } 329 + 330 + /** 331 + * Get the model mapper. 332 + */ 333 + public function getModelMapper(): ModelMapper 334 + { 335 + return $this->modelMapper; 271 336 } 272 337 }
+238
src/Generator/ModelMapper.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Generator; 4 + 5 + class ModelMapper 6 + { 7 + /** 8 + * Naming converter instance. 9 + */ 10 + protected NamingConverter $naming; 11 + 12 + /** 13 + * Type mapper instance. 14 + */ 15 + protected TypeMapper $typeMapper; 16 + 17 + /** 18 + * Create a new ModelMapper. 19 + */ 20 + public function __construct(?NamingConverter $naming = null, ?TypeMapper $typeMapper = null) 21 + { 22 + $this->naming = $naming ?? new NamingConverter; 23 + $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming); 24 + } 25 + 26 + /** 27 + * Generate toModel method body. 28 + * 29 + * @param array<string, array<string, mixed>> $properties 30 + * @param string $modelClass 31 + */ 32 + public function generateToModelBody(array $properties, string $modelClass = 'Model'): string 33 + { 34 + if (empty($properties)) { 35 + return " return new {$modelClass}();"; 36 + } 37 + 38 + $lines = []; 39 + $lines[] = " return new {$modelClass}(["; 40 + 41 + foreach ($properties as $name => $definition) { 42 + $mapping = $this->generatePropertyToModel($name, $definition); 43 + $lines[] = " '{$name}' => {$mapping},"; 44 + } 45 + 46 + $lines[] = ' ]);'; 47 + 48 + return implode("\n", $lines); 49 + } 50 + 51 + /** 52 + * Generate fromModel method body. 53 + * 54 + * @param array<string, array<string, mixed>> $properties 55 + */ 56 + public function generateFromModelBody(array $properties): string 57 + { 58 + if (empty($properties)) { 59 + return ' return new static();'; 60 + } 61 + 62 + $lines = []; 63 + $lines[] = ' return new static('; 64 + 65 + foreach ($properties as $name => $definition) { 66 + $mapping = $this->generatePropertyFromModel($name, $definition); 67 + $lines[] = " {$name}: {$mapping},"; 68 + } 69 + 70 + // Remove trailing comma from last line 71 + $lastIndex = count($lines) - 1; 72 + $lines[$lastIndex] = rtrim($lines[$lastIndex], ','); 73 + 74 + $lines[] = ' );'; 75 + 76 + return implode("\n", $lines); 77 + } 78 + 79 + /** 80 + * Generate property mapping to model. 81 + * 82 + * @param array<string, mixed> $definition 83 + */ 84 + protected function generatePropertyToModel(string $name, array $definition): string 85 + { 86 + $type = $definition['type'] ?? 'unknown'; 87 + 88 + // Handle DateTime types 89 + if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') { 90 + return "\$this->{$name}?->format('Y-m-d H:i:s')"; 91 + } 92 + 93 + // Handle blob types 94 + if ($type === 'blob') { 95 + return "\$this->{$name}?->toArray()"; 96 + } 97 + 98 + // Handle nested refs 99 + if ($type === 'ref') { 100 + return "\$this->{$name}?->toArray()"; 101 + } 102 + 103 + // Handle arrays of refs 104 + if ($type === 'array' && isset($definition['items']['type']) && $definition['items']['type'] === 'ref') { 105 + return "array_map(fn (\$item) => \$item->toArray(), \$this->{$name} ?? [])"; 106 + } 107 + 108 + // Handle arrays of objects 109 + if ($type === 'array' && isset($definition['items']['type']) && $definition['items']['type'] === 'object') { 110 + return "\$this->{$name} ?? []"; 111 + } 112 + 113 + // Simple property 114 + return "\$this->{$name}"; 115 + } 116 + 117 + /** 118 + * Generate property mapping from model. 119 + * 120 + * @param array<string, mixed> $definition 121 + */ 122 + protected function generatePropertyFromModel(string $name, array $definition): string 123 + { 124 + $type = $definition['type'] ?? 'unknown'; 125 + 126 + // Handle DateTime types 127 + if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') { 128 + return "\$model->{$name} ? new \\DateTime(\$model->{$name}) : null"; 129 + } 130 + 131 + // Handle blob types 132 + if ($type === 'blob') { 133 + return "\$model->{$name} ? \\SocialDept\\Schema\\Data\\BlobReference::fromArray(\$model->{$name}) : null"; 134 + } 135 + 136 + // Handle nested refs 137 + if ($type === 'ref' && isset($definition['ref'])) { 138 + $refClass = $this->naming->nsidToClassName($definition['ref']); 139 + $className = basename(str_replace('\\', '/', $refClass)); 140 + 141 + return "\$model->{$name} ? {$className}::fromArray(\$model->{$name}) : null"; 142 + } 143 + 144 + // Handle arrays of refs 145 + if ($type === 'array' && isset($definition['items']['type']) && $definition['items']['type'] === 'ref') { 146 + $refClass = $this->naming->nsidToClassName($definition['items']['ref']); 147 + $className = basename(str_replace('\\', '/', $refClass)); 148 + 149 + return "\$model->{$name} ? array_map(fn (\$item) => {$className}::fromArray(\$item), \$model->{$name}) : []"; 150 + } 151 + 152 + // Simple property with null coalescing 153 + return "\$model->{$name} ?? null"; 154 + } 155 + 156 + /** 157 + * Get field mapping configuration. 158 + * 159 + * @param array<string, array<string, mixed>> $properties 160 + * @return array<string, string> 161 + */ 162 + public function getFieldMapping(array $properties): array 163 + { 164 + $mapping = []; 165 + 166 + foreach ($properties as $name => $definition) { 167 + // Convert camelCase to snake_case for database columns 168 + $mapping[$name] = $this->naming->toSnakeCase($name); 169 + } 170 + 171 + return $mapping; 172 + } 173 + 174 + /** 175 + * Check if property needs special handling. 176 + * 177 + * @param array<string, mixed> $definition 178 + */ 179 + public function needsTransformer(array $definition): bool 180 + { 181 + $type = $definition['type'] ?? 'unknown'; 182 + 183 + if ($type === 'blob') { 184 + return true; 185 + } 186 + 187 + if ($type === 'ref') { 188 + return true; 189 + } 190 + 191 + if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') { 192 + return true; 193 + } 194 + 195 + if ($type === 'array' && isset($definition['items']['type'])) { 196 + $itemType = $definition['items']['type']; 197 + if (in_array($itemType, ['ref', 'object'])) { 198 + return true; 199 + } 200 + } 201 + 202 + return false; 203 + } 204 + 205 + /** 206 + * Get transformer type for property. 207 + * 208 + * @param array<string, mixed> $definition 209 + */ 210 + public function getTransformerType(array $definition): ?string 211 + { 212 + $type = $definition['type'] ?? 'unknown'; 213 + 214 + if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') { 215 + return 'datetime'; 216 + } 217 + 218 + if ($type === 'blob') { 219 + return 'blob'; 220 + } 221 + 222 + if ($type === 'ref') { 223 + return 'ref'; 224 + } 225 + 226 + if ($type === 'array' && isset($definition['items']['type'])) { 227 + $itemType = $definition['items']['type']; 228 + if ($itemType === 'ref') { 229 + return 'array_ref'; 230 + } 231 + if ($itemType === 'object') { 232 + return 'array_object'; 233 + } 234 + } 235 + 236 + return null; 237 + } 238 + }
+35
tests/Unit/Generator/MethodGeneratorTest.php
··· 319 319 $this->assertStringContainsString('*/', $method); 320 320 } 321 321 322 + public function test_it_generates_to_model_method(): void 323 + { 324 + $method = $this->generator->generateToModel([ 325 + 'name' => ['type' => 'string'], 326 + 'age' => ['type' => 'integer'], 327 + ], 'User'); 328 + 329 + $this->assertStringContainsString('public function toModel(): User', $method); 330 + $this->assertStringContainsString('* Convert to a Laravel model instance.', $method); 331 + $this->assertStringContainsString('return new User([', $method); 332 + $this->assertStringContainsString("'name' => \$this->name,", $method); 333 + $this->assertStringContainsString("'age' => \$this->age,", $method); 334 + } 335 + 336 + public function test_it_generates_from_model_method(): void 337 + { 338 + $method = $this->generator->generateFromModel([ 339 + 'name' => ['type' => 'string'], 340 + 'age' => ['type' => 'integer'], 341 + ], 'User'); 342 + 343 + $this->assertStringContainsString('public static function fromModel(User $model): static', $method); 344 + $this->assertStringContainsString('* Create an instance from a Laravel model.', $method); 345 + $this->assertStringContainsString('return new static(', $method); 346 + $this->assertStringContainsString('name: $model->name ?? null,', $method); 347 + $this->assertStringContainsString('age: $model->age ?? null', $method); 348 + } 349 + 350 + public function test_it_gets_model_mapper(): void 351 + { 352 + $mapper = $this->generator->getModelMapper(); 353 + 354 + $this->assertInstanceOf(\SocialDept\Schema\Generator\ModelMapper::class, $mapper); 355 + } 356 + 322 357 /** 323 358 * Helper to create a test document. 324 359 *
+290
tests/Unit/Generator/ModelMapperTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Generator; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Generator\ModelMapper; 7 + 8 + class ModelMapperTest extends TestCase 9 + { 10 + protected ModelMapper $mapper; 11 + 12 + protected function setUp(): void 13 + { 14 + parent::setUp(); 15 + 16 + $this->mapper = new ModelMapper; 17 + } 18 + 19 + public function test_it_generates_to_model_body_for_simple_properties(): void 20 + { 21 + $body = $this->mapper->generateToModelBody([ 22 + 'name' => ['type' => 'string'], 23 + 'age' => ['type' => 'integer'], 24 + ], 'User'); 25 + 26 + $this->assertStringContainsString('return new User([', $body); 27 + $this->assertStringContainsString("'name' => \$this->name,", $body); 28 + $this->assertStringContainsString("'age' => \$this->age,", $body); 29 + } 30 + 31 + public function test_it_generates_from_model_body_for_simple_properties(): void 32 + { 33 + $body = $this->mapper->generateFromModelBody([ 34 + 'name' => ['type' => 'string'], 35 + 'age' => ['type' => 'integer'], 36 + ]); 37 + 38 + $this->assertStringContainsString('return new static(', $body); 39 + $this->assertStringContainsString('name: $model->name ?? null,', $body); 40 + $this->assertStringContainsString('age: $model->age ?? null', $body); 41 + } 42 + 43 + public function test_it_handles_datetime_in_to_model(): void 44 + { 45 + $body = $this->mapper->generateToModelBody([ 46 + 'createdAt' => [ 47 + 'type' => 'string', 48 + 'format' => 'datetime', 49 + ], 50 + ]); 51 + 52 + $this->assertStringContainsString("\$this->createdAt?->format('Y-m-d H:i:s')", $body); 53 + } 54 + 55 + public function test_it_handles_datetime_in_from_model(): void 56 + { 57 + $body = $this->mapper->generateFromModelBody([ 58 + 'createdAt' => [ 59 + 'type' => 'string', 60 + 'format' => 'datetime', 61 + ], 62 + ]); 63 + 64 + $this->assertStringContainsString('$model->createdAt ? new \\DateTime($model->createdAt) : null', $body); 65 + } 66 + 67 + public function test_it_handles_blob_in_to_model(): void 68 + { 69 + $body = $this->mapper->generateToModelBody([ 70 + 'image' => ['type' => 'blob'], 71 + ]); 72 + 73 + $this->assertStringContainsString('$this->image?->toArray()', $body); 74 + } 75 + 76 + public function test_it_handles_blob_in_from_model(): void 77 + { 78 + $body = $this->mapper->generateFromModelBody([ 79 + 'image' => ['type' => 'blob'], 80 + ]); 81 + 82 + $this->assertStringContainsString('\\SocialDept\\Schema\\Data\\BlobReference::fromArray', $body); 83 + } 84 + 85 + public function test_it_handles_ref_in_to_model(): void 86 + { 87 + $body = $this->mapper->generateToModelBody([ 88 + 'author' => [ 89 + 'type' => 'ref', 90 + 'ref' => 'app.test.author', 91 + ], 92 + ]); 93 + 94 + $this->assertStringContainsString('$this->author?->toArray()', $body); 95 + } 96 + 97 + public function test_it_handles_ref_in_from_model(): void 98 + { 99 + $body = $this->mapper->generateFromModelBody([ 100 + 'author' => [ 101 + 'type' => 'ref', 102 + 'ref' => 'app.test.author', 103 + ], 104 + ]); 105 + 106 + $this->assertStringContainsString('Author::fromArray', $body); 107 + } 108 + 109 + public function test_it_handles_array_of_refs_in_to_model(): void 110 + { 111 + $body = $this->mapper->generateToModelBody([ 112 + 'posts' => [ 113 + 'type' => 'array', 114 + 'items' => [ 115 + 'type' => 'ref', 116 + 'ref' => 'app.test.post', 117 + ], 118 + ], 119 + ]); 120 + 121 + $this->assertStringContainsString('array_map(fn ($item) => $item->toArray()', $body); 122 + } 123 + 124 + public function test_it_handles_array_of_refs_in_from_model(): void 125 + { 126 + $body = $this->mapper->generateFromModelBody([ 127 + 'posts' => [ 128 + 'type' => 'array', 129 + 'items' => [ 130 + 'type' => 'ref', 131 + 'ref' => 'app.test.post', 132 + ], 133 + ], 134 + ]); 135 + 136 + $this->assertStringContainsString('array_map(fn ($item) => Post::fromArray($item)', $body); 137 + } 138 + 139 + public function test_it_handles_array_of_objects(): void 140 + { 141 + $body = $this->mapper->generateToModelBody([ 142 + 'settings' => [ 143 + 'type' => 'array', 144 + 'items' => [ 145 + 'type' => 'object', 146 + ], 147 + ], 148 + ]); 149 + 150 + $this->assertStringContainsString('$this->settings ?? []', $body); 151 + } 152 + 153 + public function test_it_generates_empty_to_model_for_no_properties(): void 154 + { 155 + $body = $this->mapper->generateToModelBody([], 'User'); 156 + 157 + $this->assertStringContainsString('return new User();', $body); 158 + } 159 + 160 + public function test_it_generates_empty_from_model_for_no_properties(): void 161 + { 162 + $body = $this->mapper->generateFromModelBody([]); 163 + 164 + $this->assertStringContainsString('return new static();', $body); 165 + } 166 + 167 + public function test_it_gets_field_mapping(): void 168 + { 169 + $mapping = $this->mapper->getFieldMapping([ 170 + 'userName' => ['type' => 'string'], 171 + 'emailAddress' => ['type' => 'string'], 172 + ]); 173 + 174 + $this->assertSame([ 175 + 'userName' => 'user_name', 176 + 'emailAddress' => 'email_address', 177 + ], $mapping); 178 + } 179 + 180 + public function test_it_checks_if_datetime_needs_transformer(): void 181 + { 182 + $this->assertTrue($this->mapper->needsTransformer([ 183 + 'type' => 'string', 184 + 'format' => 'datetime', 185 + ])); 186 + } 187 + 188 + public function test_it_checks_if_blob_needs_transformer(): void 189 + { 190 + $this->assertTrue($this->mapper->needsTransformer(['type' => 'blob'])); 191 + } 192 + 193 + public function test_it_checks_if_ref_needs_transformer(): void 194 + { 195 + $this->assertTrue($this->mapper->needsTransformer(['type' => 'ref'])); 196 + } 197 + 198 + public function test_it_checks_if_array_of_refs_needs_transformer(): void 199 + { 200 + $this->assertTrue($this->mapper->needsTransformer([ 201 + 'type' => 'array', 202 + 'items' => ['type' => 'ref'], 203 + ])); 204 + } 205 + 206 + public function test_it_checks_if_simple_type_needs_transformer(): void 207 + { 208 + $this->assertFalse($this->mapper->needsTransformer(['type' => 'string'])); 209 + $this->assertFalse($this->mapper->needsTransformer(['type' => 'integer'])); 210 + } 211 + 212 + public function test_it_gets_datetime_transformer_type(): void 213 + { 214 + $type = $this->mapper->getTransformerType([ 215 + 'type' => 'string', 216 + 'format' => 'datetime', 217 + ]); 218 + 219 + $this->assertSame('datetime', $type); 220 + } 221 + 222 + public function test_it_gets_blob_transformer_type(): void 223 + { 224 + $type = $this->mapper->getTransformerType(['type' => 'blob']); 225 + 226 + $this->assertSame('blob', $type); 227 + } 228 + 229 + public function test_it_gets_ref_transformer_type(): void 230 + { 231 + $type = $this->mapper->getTransformerType(['type' => 'ref']); 232 + 233 + $this->assertSame('ref', $type); 234 + } 235 + 236 + public function test_it_gets_array_ref_transformer_type(): void 237 + { 238 + $type = $this->mapper->getTransformerType([ 239 + 'type' => 'array', 240 + 'items' => ['type' => 'ref'], 241 + ]); 242 + 243 + $this->assertSame('array_ref', $type); 244 + } 245 + 246 + public function test_it_gets_array_object_transformer_type(): void 247 + { 248 + $type = $this->mapper->getTransformerType([ 249 + 'type' => 'array', 250 + 'items' => ['type' => 'object'], 251 + ]); 252 + 253 + $this->assertSame('array_object', $type); 254 + } 255 + 256 + public function test_it_gets_null_transformer_for_simple_types(): void 257 + { 258 + $this->assertNull($this->mapper->getTransformerType(['type' => 'string'])); 259 + $this->assertNull($this->mapper->getTransformerType(['type' => 'integer'])); 260 + } 261 + 262 + public function test_it_handles_custom_model_class(): void 263 + { 264 + $body = $this->mapper->generateToModelBody([ 265 + 'name' => ['type' => 'string'], 266 + ], 'CustomModel'); 267 + 268 + $this->assertStringContainsString('return new CustomModel([', $body); 269 + } 270 + 271 + public function test_it_does_not_add_trailing_comma_to_last_property(): void 272 + { 273 + $body = $this->mapper->generateFromModelBody([ 274 + 'first' => ['type' => 'string'], 275 + 'last' => ['type' => 'string'], 276 + ]); 277 + 278 + // Should not have comma after last property 279 + $lines = explode("\n", $body); 280 + $lastPropertyLine = ''; 281 + foreach ($lines as $line) { 282 + if (str_contains($line, 'last:')) { 283 + $lastPropertyLine = $line; 284 + break; 285 + } 286 + } 287 + 288 + $this->assertStringNotContainsString(',', rtrim($lastPropertyLine)); 289 + } 290 + }