Skip to content

Commit 9cb46e6

Browse files
committed
Resolve infinite loop with circular table foreign keys
1 parent 68b47f8 commit 9cb46e6

File tree

1 file changed

+81
-62
lines changed

1 file changed

+81
-62
lines changed

src/TypeScriptifyModel.php

Lines changed: 81 additions & 62 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 refer to 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(
@@ -271,10 +285,17 @@ private function getTypeScriptType(stdClass $columnSchema): string {
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+
$mappedType = (new self($fullyQualifiedRelatedModelName, $this->convertedModelsMap))->generate();
298+
}
278299
} else {
279300
// If the attribute is natively casted, we'll want to perform native cast checking
280301
// to generate the correct TypeScript type. If it's not natively casted, we can
@@ -303,56 +324,6 @@ private function convertForeignKeyToPredictedRelationName(string $attribute): st
303324
return Str::of($attribute)->replaceLast('_id', '')->camel();
304325
}
305326

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-
356327
/**
357328
* Set whether we should include the model's protected $hidden attributes.
358329
*
@@ -374,14 +345,62 @@ public function includeHidden(bool $includeHidden): self {
374345
* @throws \Exception
375346
*/
376347
public function generate(): string {
377-
if (!$this->hasValidModel()) {
378-
throw new Exception('That\'s not a valid model!');
379-
}
348+
$tableColumns = collect(DB::select(DB::raw('SHOW COLUMNS FROM ' . $this->model->getTable())));
380349

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-
}
350+
$interfaceName = Str::of($this->fullyQualifiedModelName)->afterLast('\\')->toString();
351+
$this->convertedModelsMap[$this->fullyQualifiedModelName] = $interfaceName;
352+
353+
// The output buffer always needs to start with the first `interface X {` line.
354+
$outputBuffer = collect([sprintf('interface %s {', $interfaceName)]);
384355

385-
return $this->generateInterface();
356+
$tableColumns->each(function ($columnSchema) use ($outputBuffer) {
357+
// If this attribute is hidden and we're not including hidden, we'll skip it.
358+
if (!$this->includeHidden && $this->isAttributeHidden($columnSchema->Field)) return;
359+
360+
if ($this->isAttributeRelation($columnSchema->Field)) {
361+
$relationName = $this->convertForeignKeyToPredictedRelationName($columnSchema->Field);
362+
$generatedTypeScriptType = Str::of($this->getTypeScriptType($columnSchema));
363+
364+
$isRelationNewInterfaceDefinition = $generatedTypeScriptType->startsWith('interface ');
365+
366+
if ($isRelationNewInterfaceDefinition) {
367+
$generatedInterfaceName = $generatedTypeScriptType
368+
->after('interface ')
369+
->before(' {');
370+
371+
$fullyQualifiedRelatedModelName = $this->convertForeignKeyToFullyQualifiedModelName($columnSchema->Field);
372+
$this->convertedModelsMap[$fullyQualifiedRelatedModelName] = $generatedInterfaceName->toString();
373+
374+
$generatedType = $generatedInterfaceName->append($generatedTypeScriptType->afterLast('}'));
375+
} else {
376+
$generatedType = $generatedTypeScriptType;
377+
}
378+
379+
$outputBuffer->push(sprintf(' %s: %s;', $relationName, $generatedType));
380+
381+
if ($isRelationNewInterfaceDefinition) {
382+
// Add an empty line so related interfaces aren't directly after each other.
383+
$outputBuffer->prepend('');
384+
385+
// Get the TypeScript type of this column. We know it's a relation,
386+
// therefore we know the string we get back will be another interface.
387+
// Once we've got the interface, we'll want to explode it, then prepend
388+
// each piece of the interface to the output buffer.
389+
// We reverse the exploded string because we prepend, otherwise we'd prepend backwards.
390+
$generatedTypeScriptType
391+
->beforeLast('}')
392+
->append('}')
393+
->explode("\n")
394+
->reverse()
395+
->each(fn ($str) => $outputBuffer->prepend($str));
396+
}
397+
} else {
398+
$outputBuffer->push(sprintf(' %s: %s;', $columnSchema->Field, $this->getTypeScriptType($columnSchema)));
399+
}
400+
});
401+
402+
$outputBuffer->push('}');
403+
404+
return $outputBuffer->join("\n");
386405
}
387406
}

0 commit comments

Comments
 (0)