Skip to content

Commit 4c77ce5

Browse files
authored
Merge pull request #10 from SalemC/7-resolve-relation-infinite-loop
Resolve relation infinite loop
2 parents 68b47f8 + 297fa32 commit 4c77ce5

File tree

1 file changed

+107
-67
lines changed

1 file changed

+107
-67
lines changed

src/TypeScriptifyModel.php

Lines changed: 107 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)