Skip to content

Commit 2eb52ed

Browse files
authored
feat(make:factory): auto create missing factories (zenstruck#372)
1 parent 6bc81b1 commit 2eb52ed

24 files changed

+437
-307
lines changed

src/Bundle/DependencyInjection/ZenstruckFoundryExtension.php

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container
2525
$loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
2626

2727
$loader->load('services.xml');
28+
$loader->load('maker.xml');
2829

2930
$container->registerForAutoconfiguration(Story::class)
3031
->addTag('foundry.story')
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,54 @@
11
<?php
22

3-
declare(strict_types=1);
4-
53
namespace Zenstruck\Foundry\Bundle\Maker\Factory;
64

75
use Doctrine\Persistence\ManagerRegistry;
86
use Doctrine\Persistence\Mapping\ClassMetadata;
9-
use Doctrine\Persistence\ObjectManager;
7+
use Symfony\Component\Console\Style\SymfonyStyle;
108

11-
/** @internal */
9+
/**
10+
* @internal
11+
*/
1212
abstract class AbstractDoctrineDefaultPropertiesGuesser implements DefaultPropertiesGuesser
1313
{
14-
public function __construct(protected ManagerRegistry $managerRegistry, private FactoryFinder $factoryFinder)
14+
public function __construct(protected ManagerRegistry $managerRegistry, private FactoryClassMap $factoryClassMap, private FactoryGenerator $factoryGenerator)
1515
{
1616
}
1717

1818
/** @param class-string $fieldClass */
19-
protected function addDefaultValueUsingFactory(MakeFactoryData $makeFactoryData, string $fieldName, string $fieldClass, bool $isMultiple = false): void
19+
protected function addDefaultValueUsingFactory(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery, string $fieldName, string $fieldClass): void
2020
{
21-
if (!$factoryClass = $this->factoryFinder->getFactoryForClass($fieldClass)) {
22-
$makeFactoryData->addDefaultProperty(\lcfirst($fieldName), "null, // TODO add {$fieldClass} type manually");
23-
24-
return;
21+
if (!$factoryClass = $this->factoryClassMap->getFactoryForClass($fieldClass)) {
22+
if ($makeFactoryQuery->generateAllFactories() || $io->confirm("A factory for class \"{$fieldClass}\" is missing for field {$makeFactoryData->getObjectShortName()}::\${$fieldName}. Do you want to create it?")) {
23+
$factoryClass = $this->factoryGenerator->generateFactory($io, $makeFactoryQuery->withClass($fieldClass));
24+
} else {
25+
$makeFactoryData->addDefaultProperty(\lcfirst($fieldName), "null, // TODO add {$fieldClass} type manually");
26+
27+
return;
28+
}
2529
}
2630

27-
$factoryMethod = $isMultiple ? 'new()->many(5)' : 'new()';
31+
$makeFactoryData->addUse($factoryClass);
2832

29-
$factory = new \ReflectionClass($factoryClass);
30-
$makeFactoryData->addUse($factory->getName());
31-
$makeFactoryData->addDefaultProperty(\lcfirst($fieldName), "{$factory->getShortName()}::{$factoryMethod},");
33+
$factoryShortName = \mb_substr($factoryClass, \mb_strrpos($factoryClass, '\\') + 1);
34+
$makeFactoryData->addDefaultProperty(\lcfirst($fieldName), "{$factoryShortName}::new(),");
3235
}
3336

3437
protected function getClassMetadata(MakeFactoryData $makeFactoryData): ClassMetadata
3538
{
3639
$class = $makeFactoryData->getObjectFullyQualifiedClassName();
3740

38-
$em = $this->managerRegistry->getManagerForClass($class);
41+
foreach ($this->managerRegistry->getManagers() as $manager) {
42+
try {
43+
$classMetadata = $manager->getClassMetadata($class);
44+
} catch (\Throwable) {
45+
}
46+
}
3947

40-
if (!$em instanceof ObjectManager) {
48+
if (!isset($classMetadata)) {
4149
throw new \InvalidArgumentException("\"{$class}\" is not a valid Doctrine class name.");
4250
}
4351

44-
return $em->getClassMetadata($class);
52+
return $classMetadata;
4553
}
4654
}

src/Bundle/Maker/Factory/DefaultPropertiesGuesser.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
namespace Zenstruck\Foundry\Bundle\Maker\Factory;
44

5+
use Symfony\Component\Console\Style\SymfonyStyle;
6+
57
/**
68
* @internal
79
*/
810
interface DefaultPropertiesGuesser
911
{
10-
public function __invoke(MakeFactoryData $makeFactoryData, bool $allFields): void;
12+
public function __invoke(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery): void;
1113

1214
public function supports(MakeFactoryData $makeFactoryData): bool;
1315
}

src/Bundle/Maker/Factory/DoctrineScalarFieldsDefaultPropertiesGuesser.php

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
<?php
22

3-
declare(strict_types=1);
4-
53
namespace Zenstruck\Foundry\Bundle\Maker\Factory;
64

75
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as ODMClassMetadata;
86
use Doctrine\ORM\Mapping\ClassMetadataInfo as ORMClassMetadata;
7+
use Symfony\Component\Console\Style\SymfonyStyle;
98

109
/**
1110
* @internal
@@ -21,6 +20,7 @@ final class DoctrineScalarFieldsDefaultPropertiesGuesser extends AbstractDoctrin
2120
'DATE' => 'self::faker()->dateTime(),',
2221
'DATE_MUTABLE' => 'self::faker()->dateTime(),',
2322
'DATE_IMMUTABLE' => '\DateTimeImmutable::createFromMutable(self::faker()->dateTime()),',
23+
'DATETIME' => 'self::faker()->dateTime(),',
2424
'DATETIME_MUTABLE' => 'self::faker()->dateTime(),',
2525
'DATETIME_IMMUTABLE' => '\DateTimeImmutable::createFromMutable(self::faker()->dateTime()),',
2626
'DATETIMETZ_MUTABLE' => 'self::faker()->dateTime(),',
@@ -39,7 +39,7 @@ final class DoctrineScalarFieldsDefaultPropertiesGuesser extends AbstractDoctrin
3939
'TIME_IMMUTABLE' => '\DateTimeImmutable::createFromMutable(self::faker()->datetime()),',
4040
];
4141

42-
public function __invoke(MakeFactoryData $makeFactoryData, bool $allFields): void
42+
public function __invoke(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery): void
4343
{
4444
/** @var ODMClassMetadata|ORMClassMetadata $metadata */
4545
$metadata = $this->getClassMetadata($makeFactoryData);
@@ -60,7 +60,7 @@ public function __invoke(MakeFactoryData $makeFactoryData, bool $allFields): voi
6060
}
6161

6262
// ignore identifiers and nullable fields
63-
if ((!$allFields && ($property['nullable'] ?? false)) || \in_array($fieldName, $ids, true)) {
63+
if ((!$makeFactoryQuery->isAllFields() && ($property['nullable'] ?? false)) || \in_array($fieldName, $ids, true)) {
6464
continue;
6565
}
6666

src/Bundle/Maker/Factory/FactoryFinder.php src/Bundle/Maker/Factory/FactoryClassMap.php

+14-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
<?php
22

3-
declare(strict_types=1);
4-
53
namespace Zenstruck\Foundry\Bundle\Maker\Factory;
64

75
use Zenstruck\Foundry\ModelFactory;
86

97
/**
108
* @internal
119
*/
12-
final class FactoryFinder
10+
final class FactoryClassMap
1311
{
1412
/**
1513
* @var array<class-string, class-string> factory classes as keys, object class as values
@@ -50,4 +48,17 @@ public function getFactoryForClass(string $class): ?string
5048

5149
return $factories[$class] ?? null;
5250
}
51+
52+
/**
53+
* @param class-string $factoryClass
54+
* @param class-string $class
55+
*/
56+
public function addFactoryForClass(string $factoryClass, string $class): void
57+
{
58+
if (\array_key_exists($factoryClass, $this->classesWithFactories)) {
59+
throw new \InvalidArgumentException("Factory \"{$factoryClass}\" already exists.");
60+
}
61+
62+
$this->classesWithFactories[$factoryClass] = $class;
63+
}
5364
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace Zenstruck\Foundry\Bundle\Maker\Factory;
4+
5+
use Doctrine\Persistence\ManagerRegistry;
6+
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
7+
use Symfony\Bundle\MakerBundle\Generator;
8+
use Symfony\Component\Console\Style\SymfonyStyle;
9+
use Symfony\Component\HttpKernel\KernelInterface;
10+
11+
/**
12+
* @internal
13+
*/
14+
final class FactoryGenerator
15+
{
16+
/** @param \Traversable<int, DefaultPropertiesGuesser> $defaultPropertiesGuessers */
17+
public function __construct(private ManagerRegistry $managerRegistry, private KernelInterface $kernel, private \Traversable $defaultPropertiesGuessers, private FactoryClassMap $factoryClassMap)
18+
{
19+
}
20+
21+
/**
22+
* @return class-string The factory's FQCN
23+
*/
24+
public function generateFactory(SymfonyStyle $io, MakeFactoryQuery $makeFactoryQuery): string
25+
{
26+
$class = $makeFactoryQuery->getClass();
27+
$generator = $makeFactoryQuery->getGenerator();
28+
29+
if (!\class_exists($class)) {
30+
$class = $generator->createClassNameDetails($class, 'Entity\\')->getFullName();
31+
}
32+
33+
if (!\class_exists($class)) {
34+
throw new RuntimeCommandException(\sprintf('Class "%s" not found.', $makeFactoryQuery->getClass()));
35+
}
36+
37+
$makeFactoryData = $this->createMakeFactoryData($generator, $class, $makeFactoryQuery);
38+
39+
/** @var class-string $factoryClass */
40+
$factoryClass = $makeFactoryData->getFactoryClassNameDetails()->getFullName();
41+
42+
if (!$this->factoryClassMap->classHasFactory($class)) {
43+
$this->factoryClassMap->addFactoryForClass($factoryClass, $class);
44+
}
45+
46+
foreach ($this->defaultPropertiesGuessers as $defaultPropertiesGuesser) {
47+
if ($defaultPropertiesGuesser->supports($makeFactoryData)) {
48+
$defaultPropertiesGuesser($io, $makeFactoryData, $makeFactoryQuery);
49+
}
50+
}
51+
52+
$generator->generateClass(
53+
$factoryClass,
54+
__DIR__.'/../../Resources/skeleton/Factory.tpl.php',
55+
[
56+
'makeFactoryData' => $makeFactoryData,
57+
]
58+
);
59+
60+
return $factoryClass;
61+
}
62+
63+
/** @param class-string $class */
64+
private function createMakeFactoryData(Generator $generator, string $class, MakeFactoryQuery $makeFactoryQuery): MakeFactoryData
65+
{
66+
$object = new \ReflectionClass($class);
67+
68+
$factory = $generator->createClassNameDetails(
69+
$object->getShortName(),
70+
$this->guessNamespace($generator, $makeFactoryQuery->getNamespace(), $makeFactoryQuery->isTest()),
71+
'Factory'
72+
);
73+
74+
if ($makeFactoryQuery->isPersisted()) {
75+
$repository = new \ReflectionClass($this->managerRegistry->getRepository($object->getName()));
76+
77+
if (\str_starts_with($repository->getName(), 'Doctrine')) {
78+
// not using a custom repository
79+
$repository = null;
80+
}
81+
}
82+
83+
return new MakeFactoryData(
84+
$object,
85+
$factory,
86+
$repository ?? null,
87+
$this->phpstanEnabled(),
88+
$makeFactoryQuery->isPersisted()
89+
);
90+
}
91+
92+
private function guessNamespace(Generator $generator, string $namespace, bool $test): string
93+
{
94+
// strip maker's root namespace if set
95+
if (0 === \mb_strpos($namespace, $generator->getRootNamespace())) {
96+
$namespace = \mb_substr($namespace, \mb_strlen($generator->getRootNamespace()));
97+
}
98+
99+
$namespace = \trim($namespace, '\\');
100+
101+
// if creating in tests dir, ensure namespace prefixed with Tests\
102+
if ($test && 0 !== \mb_strpos($namespace, 'Tests\\')) {
103+
$namespace = 'Tests\\'.$namespace;
104+
}
105+
106+
return $namespace;
107+
}
108+
109+
private function phpstanEnabled(): bool
110+
{
111+
return \file_exists("{$this->kernel->getProjectDir()}/vendor/phpstan/phpstan/phpstan");
112+
}
113+
}

src/Bundle/Maker/Factory/MakeFactoryData.php

+14-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace Zenstruck\Foundry\Bundle\Maker\Factory;
44

5+
use Symfony\Bundle\MakerBundle\Str;
6+
use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
57
use Zenstruck\Foundry\ModelFactory;
68
use Zenstruck\Foundry\Proxy;
79
use Zenstruck\Foundry\RepositoryProxy;
@@ -18,7 +20,7 @@ final class MakeFactoryData
1820
/** @var non-empty-list<MakeFactoryPHPDocMethod> */
1921
private array $methodsInPHPDoc;
2022

21-
public function __construct(private \ReflectionClass $object, private ?\ReflectionClass $repository, private bool $withPHPStanEnabled, private bool $persisted)
23+
public function __construct(private \ReflectionClass $object, private ClassNameDetails $factoryClassNameDetails, private ?\ReflectionClass $repository, private bool $withPHPStanEnabled, private bool $persisted)
2224
{
2325
$this->uses = [
2426
ModelFactory::class,
@@ -44,6 +46,11 @@ public function getObjectShortName(): string
4446
return $this->object->getShortName();
4547
}
4648

49+
public function getFactoryClassNameDetails(): ClassNameDetails
50+
{
51+
return $this->factoryClassNameDetails;
52+
}
53+
4754
/** @return class-string */
4855
public function getObjectFullyQualifiedClassName(): string
4956
{
@@ -65,8 +72,14 @@ public function hasPHPStanEnabled(): bool
6572
return $this->withPHPStanEnabled;
6673
}
6774

75+
/** @param class-string $use */
6876
public function addUse(string $use): void
6977
{
78+
// prevent to add an un-needed "use"
79+
if (Str::getNamespace($this->factoryClassNameDetails->getFullName()) === Str::getNamespace($use)) {
80+
return;
81+
}
82+
7083
if (!\in_array($use, $this->uses, true)) {
7184
$this->uses[] = $use;
7285
}

0 commit comments

Comments
 (0)