@@ -51,10 +51,24 @@ final class TypeScriptifyModel {
5151
5252 /**
5353 * @param string $fullyQualifiedModelName The fully qualified model class name.
54+ * @param ?array<string,string> $convertedModelsMap The map of `fully qualified model name => interface name` definitions this class can use instead of generating its own definitions.
5455 */
5556 public function __construct (
56- private readonly string $ fullyQualifiedModelName ,
57+ private string $ fullyQualifiedModelName ,
58+ private ?array &$ convertedModelsMap = []
5759 ) {
60+ // For consistency in comparisons that happen throughout the lifecycle of this class,
61+ // we need all fully qualified model names to have a leading \.
62+ $ this ->fullyQualifiedModelName = Str::of ($ fullyQualifiedModelName )->start ('\\' )->toString ();
63+
64+ if (!$ this ->hasValidModel ()) {
65+ throw new Exception ('That \'s not a valid model! ' );
66+ }
67+
68+ if (!$ this ->hasSupportedDatabaseConnection ()) {
69+ throw new Exception ('Your database connection is currently unsupported! The following database connections are supported: ' . implode (', ' , self ::SUPPORTED_DATABASE_CONNECTIONS ));
70+ }
71+
5872 $ this ->model = new $ fullyQualifiedModelName ;
5973
6074 $ this ->modelForeignKeyConstraints = collect (
@@ -171,11 +185,13 @@ private function mapNativeCastToTypeScriptType(string $attribute): string {
171185 /**
172186 * Map a database column type to a TypeScript type.
173187 *
174- * @param \Illuminate\Support\Stringable $columnType
188+ * @param string $columnType
175189 *
176190 * @return string
177191 */
178- private function mapDatabaseTypeToTypeScriptType (Stringable $ columnType ): string {
192+ private function mapDatabaseTypeToTypeScriptType (string $ columnType ): string {
193+ $ columnType = Str::of ($ columnType );
194+
179195 return match (true ) {
180196 $ columnType ->startsWith ('bit ' ) => 'number ' ,
181197 $ columnType ->startsWith ('int ' ) => 'number ' ,
@@ -266,22 +282,29 @@ private function convertForeignKeyToFullyQualifiedModelName(string $attribute):
266282 * @return string
267283 */
268284 private function getTypeScriptType (stdClass $ columnSchema ): string {
269- $ columnType = Str::of ($ columnSchema ->Type );
270-
271285 if ($ this ->isAttributeRelation ($ columnSchema ->Field )) {
272286 $ fullyQualifiedRelatedModelName = $ this ->convertForeignKeyToFullyQualifiedModelName ($ columnSchema ->Field );
273287
274- // We generate new interfaces for any relational attributes.
275- // That means we can recursively instantiate the current class to generate
276- // as many interface definitions for relational attributes as we need.
277- $ mappedType = (new self ($ fullyQualifiedRelatedModelName ))->generate ();
288+ if (array_key_exists ($ fullyQualifiedRelatedModelName , $ this ->convertedModelsMap )) {
289+ // If we've already mapped this model to a TypeScript interface definition,
290+ // we don't want to scan it again, otherwise we could potentially cause
291+ // an infinite mapping loop.
292+ $ mappedType = $ this ->convertedModelsMap [$ fullyQualifiedRelatedModelName ];
293+ } else {
294+ // We generate new interfaces for any relational attributes.
295+ // That means we can recursively instantiate the current class to generate
296+ // as many interface definitions for relational attributes as we need.
297+ // We pass our existing convertedModelsMap instance here to prevent this class
298+ // mapping models we've already mapped in this current class instance.
299+ $ mappedType = (new self ($ fullyQualifiedRelatedModelName , $ this ->convertedModelsMap ))->generate ();
300+ }
278301 } else {
279302 // If the attribute is natively casted, we'll want to perform native cast checking
280303 // to generate the correct TypeScript type. If it's not natively casted, we can
281304 // simply map the database type to a TypeScript type.
282305 $ mappedType = $ this ->isAttributeNativelyCasted ($ columnSchema ->Field )
283306 ? $ this ->mapNativeCastToTypeScriptType ($ columnSchema ->Field )
284- : $ this ->mapDatabaseTypeToTypeScriptType ($ columnType );
307+ : $ this ->mapDatabaseTypeToTypeScriptType ($ columnSchema -> Type );
285308 }
286309
287310 // We can't do much with an unknown type.
@@ -303,56 +326,6 @@ private function convertForeignKeyToPredictedRelationName(string $attribute): st
303326 return Str::of ($ attribute )->replaceLast ('_id ' , '' )->camel ();
304327 }
305328
306- /**
307- * Generate the interface.
308- *
309- * @return string
310- */
311- private function generateInterface (): string {
312- $ tableColumns = collect (DB ::select (DB ::raw ('SHOW COLUMNS FROM ' . $ this ->model ->getTable ())));
313-
314- $ outputBuffer = collect ([
315- // The output buffer always needs to start with the first `interface X {` line.
316- sprintf ('interface %s { ' , Str::of ($ this ->fullyQualifiedModelName )->afterLast ('\\' )),
317- ]);
318-
319- $ tableColumns ->each (function ($ column ) use ($ outputBuffer ) {
320- // If this attribute is hidden and we're not including hidden, we'll skip it.
321- if (!$ this ->includeHidden && $ this ->isAttributeHidden ($ column ->Field )) return ;
322-
323- if ($ this ->isAttributeRelation ($ column ->Field )) {
324- $ relationName = $ this ->convertForeignKeyToPredictedRelationName ($ column ->Field );
325- $ generatedTypeScriptType = Str::of ($ this ->getTypeScriptType ($ column ));
326-
327- $ generatedTypeName = $ generatedTypeScriptType
328- ->after ('interface ' )
329- ->before (' { ' )
330- ->append ($ generatedTypeScriptType ->afterLast ('} ' ));
331-
332- $ outputBuffer ->push (sprintf (' %s: %s; ' , $ relationName , $ generatedTypeName ));
333-
334- // Add an empty line so related interfaces aren't directly after each other.
335- $ outputBuffer ->prepend ('' );
336-
337- // Get the TypeScript type of this column. We know it's a relation,
338- // therefore we know the string we get back will be another interface.
339- // Once we've got the interface, we'll want to explode it, then prepend
340- // each piece of the interface to the output buffer.
341- // We reverse the exploded string because we prepend, otherwise we'd prepend backwards.
342- $ generatedTypeScriptType
343- ->explode ("\n" )
344- ->reverse ()
345- ->each (fn ($ str ) => $ outputBuffer ->prepend ($ str ));
346- } else {
347- $ outputBuffer ->push (sprintf (' %s: %s; ' , $ column ->Field , $ this ->getTypeScriptType ($ column )));
348- }
349- });
350-
351- $ outputBuffer ->push ('} ' );
352-
353- return $ outputBuffer ->join ("\n" );
354- }
355-
356329 /**
357330 * Set whether we should include the model's protected $hidden attributes.
358331 *
@@ -374,14 +347,81 @@ public function includeHidden(bool $includeHidden): self {
374347 * @throws \Exception
375348 */
376349 public function generate (): string {
377- if (!$ this ->hasValidModel ()) {
378- throw new Exception ('That \'s not a valid model! ' );
379- }
350+ $ tableColumns = collect (DB ::select (DB ::raw ('SHOW COLUMNS FROM ' . $ this ->model ->getTable ())));
380351
381- if (!$ this ->hasSupportedDatabaseConnection ()) {
382- throw new Exception ('Your database connection is currently unsupported! The following database connections are supported: ' . implode (', ' , self ::SUPPORTED_DATABASE_CONNECTIONS ));
383- }
352+ $ interfaceName = Str::afterLast ($ this ->fullyQualifiedModelName , '\\' );
353+
354+ // At this point, we haven't technically generated the full TypeScript interface definition
355+ // for the target model. However, if the current model was to reference itself (which is valid),
356+ // without doing this here, it would cause an infinite loop.
357+ $ this ->convertedModelsMap [$ this ->fullyQualifiedModelName ] = $ interfaceName ;
358+
359+ // The output buffer always needs to start with the first `interface X {` line.
360+ $ outputBuffer = collect ([sprintf ('interface %s { ' , $ interfaceName )]);
384361
385- return $ this ->generateInterface ();
362+ $ tableColumns ->each (function ($ columnSchema ) use ($ outputBuffer ) {
363+ // If this attribute is hidden and we're not including hidden, we'll skip it.
364+ if (!$ this ->includeHidden && $ this ->isAttributeHidden ($ columnSchema ->Field )) return ;
365+
366+ if ($ this ->isAttributeRelation ($ columnSchema ->Field )) {
367+ $ relationName = $ this ->convertForeignKeyToPredictedRelationName ($ columnSchema ->Field );
368+ $ generatedTypeScriptType = Str::of ($ this ->getTypeScriptType ($ columnSchema ));
369+
370+ // We know we've just generated a new interface if the generated TypeScript type
371+ // starts with 'interface '. If it doesn't, we'll be referring to a TypeScript type
372+ // that's been previously generated.
373+ $ isRelationInterfaceDefinition = $ generatedTypeScriptType ->startsWith ('interface ' );
374+
375+ if ($ isRelationInterfaceDefinition ) {
376+ // interface User { => User
377+ $ generatedInterfaceName = $ generatedTypeScriptType
378+ ->after ('interface ' )
379+ ->before (' { ' );
380+
381+ $ fullyQualifiedRelatedModelName = $ this ->convertForeignKeyToFullyQualifiedModelName ($ columnSchema ->Field );
382+ // We've just generated a new interface, we'll want to make sure our class doesn't attempt to
383+ // generate it again by adding it to our convertedModelsMap.
384+ $ this ->convertedModelsMap [$ fullyQualifiedRelatedModelName ] = $ generatedInterfaceName ->toString ();
385+
386+ // Columns aren't always required. To make sure we're not losing other type metadata,
387+ // we'll append everything after the end of the interface definition onto the new
388+ // interface name we've generated.
389+ $ generatedType = $ generatedInterfaceName ->append ($ generatedTypeScriptType ->afterLast ('} ' ));
390+ } else {
391+ // If we don't have a new relation interface definition, we'll have the interface name definition,
392+ // retrieved from the convertedModelsMap (as well as any type metadata). We can use that value directly.
393+ $ generatedType = $ generatedTypeScriptType ;
394+ }
395+
396+ // Append the relation to the interface we're generating.
397+ $ outputBuffer ->push (sprintf (' %s: %s; ' , $ relationName , $ generatedType ));
398+
399+ // If we've generated a new interface, we'll want to append it above the current
400+ // interface we're in the process of generating.
401+ if ($ isRelationInterfaceDefinition ) {
402+ // Add an empty line so related interfaces aren't directly after each other.
403+ $ outputBuffer ->prepend ('' );
404+
405+ // Get the TypeScript type of this column. We know it's a relation,
406+ // therefore we know the string we get back will be another interface.
407+ // Once we've got the interface, we'll want to explode it, then prepend
408+ // each piece of the interface to the output buffer.
409+ // We reverse the exploded string because we prepend, otherwise we'd prepend backwards.
410+ $ generatedTypeScriptType
411+ ->beforeLast ('} ' )
412+ ->append ('} ' )
413+ ->explode ("\n" )
414+ ->reverse ()
415+ ->each (fn ($ str ) => $ outputBuffer ->prepend ($ str ));
416+ }
417+ } else {
418+ // Append the column name, and the TypeScript type to the interface we're generating.
419+ $ outputBuffer ->push (sprintf (' %s: %s; ' , $ columnSchema ->Field , $ this ->getTypeScriptType ($ columnSchema )));
420+ }
421+ });
422+
423+ $ outputBuffer ->push ('} ' );
424+
425+ return $ outputBuffer ->join ("\n" );
386426 }
387427}
0 commit comments