├── phpbench.json ├── utils ├── rector │ ├── phpstan.neon │ ├── src │ │ ├── FoundrySetList.php │ │ ├── RemoveMethodCall │ │ │ ├── RemoveMethodCall.php │ │ │ └── RemoveMethodCallRector.php │ │ ├── RemoveFunctionCall │ │ │ ├── RemoveFunctionCall.php │ │ │ └── RemoveFunctionCallRector.php │ │ ├── MethodCallToFuncCallWithObjectAsFirstParameter │ │ │ ├── MethodCallToFuncCallWithObjectAsFirstParameter.php │ │ │ └── MethodCallToFuncCallWithObjectAsFirstParameterRector.php │ │ ├── RemoveWithoutAutorefreshCallRector.php │ │ └── RemoveUnproxifyArrayMapRector.php │ └── config │ │ └── foundry-set.php └── psalm │ ├── FoundryPlugin.php │ └── FixProxyFactoryMethodsReturnType.php ├── skeleton ├── Story.tpl.php └── Factory.tpl.php ├── src ├── Exception │ ├── PersistenceDisabled.php │ ├── PersistenceNotAvailable.php │ ├── FoundryNotBooted.php │ ├── CannotCreateFactory.php │ └── FactoriesTraitNotUsed.php ├── Persistence │ ├── Exception │ │ ├── RefreshObjectFailed.php │ │ ├── NotEnoughObjects.php │ │ ├── ObjectNoLongerExist.php │ │ ├── NoPersistenceStrategy.php │ │ └── ObjectHasUnsavedChanges.php │ ├── Relationship │ │ ├── RelationshipMetadata.php │ │ ├── ManyToOneRelationship.php │ │ ├── OneToOneRelationship.php │ │ └── OneToManyRelationship.php │ ├── ResetDatabase │ │ ├── BeforeEachTestResetter.php │ │ ├── BeforeFirstTestResetter.php │ │ └── ResetDatabaseManager.php │ ├── PersistMode.php │ ├── Event │ │ └── AfterPersist.php │ ├── Proxy.php │ ├── PersistenceStrategy.php │ ├── PersistedObjectsTracker.php │ └── RepositoryAssertions.php ├── Mongo │ ├── MongoResetter.php │ ├── MongoSchemaResetter.php │ └── MongoPersistenceStrategy.php ├── Object │ └── Event │ │ ├── Event.php │ │ ├── AfterInstantiate.php │ │ ├── BeforeInstantiate.php │ │ └── HookListenerFilter.php ├── ORM │ ├── ResetDatabase │ │ ├── ResetDatabaseMode.php │ │ ├── OrmResetter.php │ │ ├── SchemaDatabaseResetter.php │ │ ├── MigrateDatabaseResetter.php │ │ ├── DamaDatabaseResetter.php │ │ └── BaseOrmResetter.php │ ├── DoctrineOrmVersionGuesser.php │ ├── OrmV3PersistenceStrategy.php │ ├── AbstractORMPersistenceStrategy.php │ └── OrmV2PersistenceStrategy.php ├── FactoryRegistryInterface.php ├── Maker │ ├── Factory │ │ ├── Exception │ │ │ └── FactoryClassAlreadyExistException.php │ │ ├── DefaultPropertiesGuesser.php │ │ ├── AbstractDoctrineDefaultPropertiesGuesser.php │ │ ├── AbstractDefaultPropertyGuesser.php │ │ ├── ODMDefaultPropertiesGuesser.php │ │ ├── FactoryClassMap.php │ │ ├── FactoryCandidatesClassesExtractor.php │ │ ├── MakeFactoryQuery.php │ │ ├── ORMDefaultPropertiesGuesser.php │ │ ├── LegacyORMDefaultPropertiesGuesser.php │ │ ├── ObjectDefaultPropertiesGuesser.php │ │ ├── NoPersistenceObjectsAutoCompleter.php │ │ ├── MakeFactoryPHPDocMethod.php │ │ └── DoctrineScalarFieldsDefaultPropertiesGuesser.php │ ├── NamespaceGuesser.php │ └── MakeStory.php ├── Attribute │ ├── AsFixture.php │ ├── AsFoundryHook.php │ └── WithStory.php ├── ArrayFactory.php ├── PHPUnit │ ├── DisplayFakerSeedOnTestSuiteFinished.php │ ├── ShutdownFoundryOnDataProviderMethodFinished.php │ ├── BootFoundryOnDataProviderMethodCalled.php │ ├── EnableInMemoryBeforeTest.php │ ├── FoundryExtension.php │ └── BuildStoryOnTestPrepared.php ├── InMemory │ ├── CannotEnableInMemory.php │ ├── InMemoryRepository.php │ ├── AsInMemoryTest.php │ ├── InMemoryRepositoryTrait.php │ ├── InMemoryRepositoryRegistry.php │ ├── GenericInMemoryRepository.php │ ├── InMemoryFactoryRegistry.php │ ├── DependencyInjection │ │ └── InMemoryCompilerPass.php │ └── InMemoryDoctrineObjectRepositoryAdapter.php ├── ForceValue.php ├── Command │ ├── StubCommand.php │ └── LoadFixturesCommand.php ├── FactoryRegistry.php ├── symfony_console.php ├── Test │ ├── UnitTestConfig.php │ └── ResetDatabase.php ├── AnonymousFactoryGenerator.php ├── LazyValue.php ├── DependencyInjection │ └── AsFixtureStoryCompilerPass.php ├── functions.php └── StoryRegistry.php ├── phpunit-rector.xml.dist ├── config ├── in_memory.php ├── mongo.php ├── command_stubs.php ├── orm.php ├── services.php └── persistence.php ├── .roave-backward-compatibility-check.xml ├── LICENSE ├── docs └── .doctor-rst.yaml ├── phpunit-deprecation-baseline.xml └── composer.json /phpbench.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./bin/tools/phpbench/vendor/phpbench/phpbench/phpbench.schema.json", 3 | "runner.bootstrap": "./vendor/autoload.php", 4 | "runner.php_env": { 5 | "KERNEL_CLASS": "Zenstruck\\Foundry\\Tests\\Fixture\\TestKernel", 6 | "APP_ENV": "dev" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /utils/rector/phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | inferPrivatePropertyTypeFromConstructor: true 3 | checkUninitializedProperties: true 4 | paths: 5 | - ./src 6 | - ./tests 7 | level: 8 8 | bootstrapFiles: 9 | - ./tests/bootstrap.php 10 | excludePaths: 11 | - ./tests/Fixtures 12 | -------------------------------------------------------------------------------- /skeleton/Story.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | use Zenstruck\Foundry\Story; 6 | 7 | final class extends Story 8 | { 9 | public function build(): void 10 | { 11 | // TODO build your story here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#stories) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Exception/PersistenceDisabled.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Exception; 13 | 14 | final class PersistenceDisabled extends \LogicException 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Persistence/Exception/RefreshObjectFailed.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Persistence\Exception; 15 | 16 | abstract class RefreshObjectFailed extends \RuntimeException 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /src/Exception/PersistenceNotAvailable.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Exception; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class PersistenceNotAvailable extends \LogicException 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /utils/rector/src/FoundrySetList.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Utils\Rector; 13 | 14 | final class FoundrySetList 15 | { 16 | /** @var string */ 17 | public const REMOVE_PROXIES = __DIR__.'/../config/foundry-set.php'; 18 | } 19 | -------------------------------------------------------------------------------- /src/Persistence/Exception/NotEnoughObjects.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Persistence\Exception; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class NotEnoughObjects extends \RuntimeException 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /src/Mongo/MongoResetter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Mongo; 15 | 16 | use Zenstruck\Foundry\Persistence\ResetDatabase\BeforeEachTestResetter; 17 | 18 | interface MongoResetter extends BeforeEachTestResetter 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /utils/rector/src/RemoveMethodCall/RemoveMethodCall.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Utils\Rector\RemoveMethodCall; 13 | 14 | final class RemoveMethodCall 15 | { 16 | public function __construct( 17 | public readonly string $methodName, 18 | ) { 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /utils/rector/src/RemoveFunctionCall/RemoveFunctionCall.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Utils\Rector\RemoveFunctionCall; 13 | 14 | final class RemoveFunctionCall 15 | { 16 | public function __construct( 17 | public readonly string $functionName, 18 | ) { 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Object/Event/Event.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Object\Event; 15 | 16 | /** 17 | * @template T of object 18 | */ 19 | interface Event 20 | { 21 | /** 22 | * @return class-string 23 | */ 24 | public function objectClassName(): string; 25 | } 26 | -------------------------------------------------------------------------------- /src/Persistence/Relationship/RelationshipMetadata.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Persistence\Relationship; 13 | 14 | /** 15 | * @author Kevin Bond 16 | * 17 | * @internal 18 | */ 19 | interface RelationshipMetadata 20 | { 21 | public function inverseField(): string; 22 | } 23 | -------------------------------------------------------------------------------- /src/ORM/ResetDatabase/ResetDatabaseMode.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\ORM\ResetDatabase; 15 | 16 | /** 17 | * @author Nicolas PHILIPPE 18 | */ 19 | enum ResetDatabaseMode: string 20 | { 21 | case SCHEMA = 'schema'; 22 | case MIGRATE = 'migrate'; 23 | } 24 | -------------------------------------------------------------------------------- /src/ORM/DoctrineOrmVersionGuesser.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\ORM; 15 | 16 | use Doctrine\ORM\Mapping\FieldMapping; 17 | 18 | final class DoctrineOrmVersionGuesser 19 | { 20 | public static function isOrmV3(): bool 21 | { 22 | return \class_exists(FieldMapping::class); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Persistence/Exception/ObjectNoLongerExist.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Persistence\Exception; 15 | 16 | final class ObjectNoLongerExist extends RefreshObjectFailed 17 | { 18 | public function __construct(public readonly object $originalObject) 19 | { 20 | parent::__construct('object no longer exists...'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Persistence/ResetDatabase/BeforeEachTestResetter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Persistence\ResetDatabase; 15 | 16 | use Symfony\Component\HttpKernel\KernelInterface; 17 | 18 | /** 19 | * @author Nicolas PHILIPPE 20 | */ 21 | interface BeforeEachTestResetter 22 | { 23 | public function resetBeforeEachTest(KernelInterface $kernel): void; 24 | } 25 | -------------------------------------------------------------------------------- /src/Persistence/ResetDatabase/BeforeFirstTestResetter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Persistence\ResetDatabase; 15 | 16 | use Symfony\Component\HttpKernel\KernelInterface; 17 | 18 | /** 19 | * @author Nicolas PHILIPPE 20 | */ 21 | interface BeforeFirstTestResetter 22 | { 23 | public function resetBeforeFirstTest(KernelInterface $kernel): void; 24 | } 25 | -------------------------------------------------------------------------------- /src/FactoryRegistryInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry; 13 | 14 | /** 15 | * @author Nicolas PHILIPPE 16 | * 17 | * @internal 18 | */ 19 | interface FactoryRegistryInterface 20 | { 21 | /** 22 | * @template T of Factory 23 | * 24 | * @param class-string $class 25 | * 26 | * @return T 27 | */ 28 | public function get(string $class): Factory; 29 | } 30 | -------------------------------------------------------------------------------- /src/Maker/Factory/Exception/FactoryClassAlreadyExistException.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Maker\Factory\Exception; 15 | 16 | final class FactoryClassAlreadyExistException extends \InvalidArgumentException 17 | { 18 | public function __construct(string $factoryClass) 19 | { 20 | parent::__construct("Factory \"{$factoryClass}\" already exists."); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Exception/FoundryNotBooted.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Exception; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class FoundryNotBooted extends \LogicException 18 | { 19 | public function __construct() 20 | { 21 | parent::__construct('Foundry is not yet booted. Ensure ZenstruckFoundryBundle is enabled. If in a test, ensure your TestCase has the Factories trait.'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /utils/rector/src/MethodCallToFuncCallWithObjectAsFirstParameter/MethodCallToFuncCallWithObjectAsFirstParameter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Utils\Rector\MethodCallToFuncCallWithObjectAsFirstParameter; 13 | 14 | final class MethodCallToFuncCallWithObjectAsFirstParameter 15 | { 16 | public function __construct( 17 | public readonly string $methodName, 18 | public readonly string $functionName, 19 | ) { 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Attribute/AsFixture.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Attribute; 15 | 16 | /** 17 | * @author Nicolas PHILIPPE 18 | */ 19 | #[\Attribute(\Attribute::TARGET_CLASS)] 20 | final class AsFixture 21 | { 22 | public function __construct( 23 | public readonly string $name, 24 | /** @var list */ 25 | public readonly array $groups = [], 26 | ) { 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Maker/Factory/DefaultPropertiesGuesser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker\Factory; 13 | 14 | use Symfony\Component\Console\Style\SymfonyStyle; 15 | 16 | /** 17 | * @internal 18 | */ 19 | interface DefaultPropertiesGuesser 20 | { 21 | public function __invoke(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery): void; 22 | 23 | public function supports(MakeFactoryData $makeFactoryData): bool; 24 | } 25 | -------------------------------------------------------------------------------- /src/ORM/ResetDatabase/OrmResetter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\ORM\ResetDatabase; 15 | 16 | use Zenstruck\Foundry\Persistence\ResetDatabase\BeforeEachTestResetter; 17 | use Zenstruck\Foundry\Persistence\ResetDatabase\BeforeFirstTestResetter; 18 | 19 | /** 20 | * @author Nicolas PHILIPPE 21 | */ 22 | interface OrmResetter extends BeforeFirstTestResetter, BeforeEachTestResetter 23 | { 24 | } 25 | -------------------------------------------------------------------------------- /src/ArrayFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry; 13 | 14 | /** 15 | * @author Kevin Bond 16 | * 17 | * @phpstan-import-type Parameters from Factory 18 | * @extends Factory 19 | */ 20 | abstract class ArrayFactory extends Factory 21 | { 22 | final public function create(callable|array $attributes = []): array 23 | { 24 | return $this->normalizeParameters($this->normalizeAttributes($attributes)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Persistence/PersistMode.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Persistence; 15 | 16 | /** 17 | * @internal 18 | * @author Nicolas PHILIPPE 19 | */ 20 | enum PersistMode 21 | { 22 | case PERSIST; 23 | case WITHOUT_PERSISTING; 24 | case NO_PERSIST_BUT_SCHEDULE_FOR_INSERT; 25 | 26 | public function isPersisting(): bool 27 | { 28 | return self::WITHOUT_PERSISTING !== $this; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Exception/CannotCreateFactory.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Exception; 15 | 16 | /** 17 | * @author Nicolas PHILIPPE 18 | * @internal 19 | */ 20 | final class CannotCreateFactory extends \LogicException 21 | { 22 | public static function argumentCountError(\ArgumentCountError $e): static 23 | { 24 | return new self('Factories with dependencies (services) cannot be created before foundry is booted.', previous: $e); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Attribute/AsFoundryHook.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Attribute; 15 | 16 | use Symfony\Component\EventDispatcher\Attribute\AsEventListener; 17 | 18 | #[\Attribute(\Attribute::TARGET_METHOD)] 19 | final class AsFoundryHook extends AsEventListener 20 | { 21 | public function __construct( 22 | /** @var class-string|null */ 23 | public readonly ?string $objectClass = null, 24 | int $priority = 0, 25 | ) { 26 | parent::__construct(priority: $priority); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /utils/psalm/FoundryPlugin.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Psalm; 13 | 14 | use Psalm\Plugin\PluginEntryPointInterface; 15 | use Psalm\Plugin\RegistrationInterface; 16 | 17 | final class FoundryPlugin implements PluginEntryPointInterface 18 | { 19 | public function __invoke(RegistrationInterface $registration, ?\SimpleXMLElement $config = null): void 20 | { 21 | \class_exists(FixProxyFactoryMethodsReturnType::class, true); 22 | $registration->registerHooksFromClass(FixProxyFactoryMethodsReturnType::class); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Persistence/Exception/NoPersistenceStrategy.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Persistence\Exception; 15 | 16 | final class NoPersistenceStrategy extends \LogicException 17 | { 18 | /** 19 | * @param class-string $class 20 | */ 21 | public function __construct(string $class, ?\Throwable $previous = null) 22 | { 23 | parent::__construct( 24 | \sprintf('No persistence strategy found for "%s".', $class), 25 | previous: $previous 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/PHPUnit/DisplayFakerSeedOnTestSuiteFinished.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\PHPUnit; 15 | 16 | use PHPUnit\Event\TestRunner\Finished; 17 | use PHPUnit\Event\TestRunner\FinishedSubscriber; 18 | use Zenstruck\Foundry\Configuration; 19 | 20 | final class DisplayFakerSeedOnTestSuiteFinished implements FinishedSubscriber 21 | { 22 | public function notify(Finished $event): void 23 | { 24 | echo "\n\nFaker seed: ".Configuration::fakerSeed(); // @phpstan-ignore ekinoBannedCode.expression 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Persistence/Relationship/ManyToOneRelationship.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Persistence\Relationship; 15 | 16 | /** 17 | * @author Nicolas PHILIPPE 18 | * 19 | * @internal 20 | */ 21 | final class ManyToOneRelationship implements RelationshipMetadata 22 | { 23 | public function __construct( 24 | private readonly string $inverseField, 25 | ) { 26 | } 27 | 28 | public function inverseField(): string 29 | { 30 | return $this->inverseField; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Persistence/Exception/ObjectHasUnsavedChanges.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Persistence\Exception; 15 | 16 | final class ObjectHasUnsavedChanges extends RefreshObjectFailed 17 | { 18 | public function __construct(string $objectClass) 19 | { 20 | parent::__construct( 21 | "Cannot auto refresh \"{$objectClass}\" as there are unsaved changes. Be sure to call ->_save() or disable auto refreshing (see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#auto-refresh for details)." 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/InMemory/CannotEnableInMemory.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\InMemory; 15 | 16 | final class CannotEnableInMemory extends \LogicException 17 | { 18 | public static function testIsNotAKernelTestCase(string $testName): self 19 | { 20 | return new self("{$testName}: Cannot use the #[AsInMemoryTest] attribute without extending KernelTestCase."); 21 | } 22 | 23 | public static function noInMemoryRepositoryRegistry(): self 24 | { 25 | return new self('Cannot enable "in memory": maybe not in a KernelTestCase?'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Persistence/Relationship/OneToOneRelationship.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Persistence\Relationship; 15 | 16 | /** 17 | * @author Nicolas PHILIPPE 18 | * 19 | * @internal 20 | */ 21 | final class OneToOneRelationship implements RelationshipMetadata 22 | { 23 | public function __construct( 24 | private readonly string $inverseField, 25 | public readonly bool $isOwning, 26 | ) { 27 | } 28 | 29 | public function inverseField(): string 30 | { 31 | return $this->inverseField; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /phpunit-rector.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ./utils/rector/tests/ 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/InMemory/InMemoryRepository.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\InMemory; 15 | 16 | /** 17 | * @author Nicolas PHILIPPE 18 | * 19 | * @template T of object 20 | * @experimental 21 | */ 22 | interface InMemoryRepository 23 | { 24 | /** 25 | * @return class-string 26 | */ 27 | public static function _class(): string; 28 | 29 | /** 30 | * @param T $item 31 | */ 32 | public function _save(object $item): void; 33 | 34 | /** 35 | * @return list 36 | */ 37 | public function _all(): array; 38 | } 39 | -------------------------------------------------------------------------------- /src/Persistence/Relationship/OneToManyRelationship.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Persistence\Relationship; 15 | 16 | /** 17 | * @author Nicolas PHILIPPE 18 | * 19 | * @internal 20 | */ 21 | final class OneToManyRelationship implements RelationshipMetadata 22 | { 23 | public function __construct( 24 | private readonly string $inverseField, 25 | public readonly ?string $collectionIndexedBy, 26 | ) { 27 | } 28 | 29 | public function inverseField(): string 30 | { 31 | return $this->inverseField; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Attribute/WithStory.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Attribute; 15 | 16 | use Zenstruck\Foundry\Story; 17 | 18 | #[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] 19 | final class WithStory 20 | { 21 | public function __construct( 22 | /** @var class-string $story */ 23 | public readonly string $story, 24 | ) { 25 | if (!\is_subclass_of($story, Story::class)) { 26 | throw new \InvalidArgumentException(\sprintf('"%s" is not a valid story class.', $story)); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/PHPUnit/ShutdownFoundryOnDataProviderMethodFinished.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\PHPUnit; 15 | 16 | use PHPUnit\Event; 17 | 18 | /** 19 | * @internal 20 | * @author Nicolas PHILIPPE 21 | */ 22 | final class ShutdownFoundryOnDataProviderMethodFinished implements Event\Test\DataProviderMethodFinishedSubscriber 23 | { 24 | public function notify(Event\Test\DataProviderMethodFinished $event): void 25 | { 26 | if (\method_exists($event->testMethod()->className(), '_shutdownAfterDataProvider')) { 27 | $event->testMethod()->className()::_shutdownAfterDataProvider(); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ForceValue.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry; 13 | 14 | /** 15 | * @author NIcolas PHILIPPE 16 | * 17 | * @internal 18 | */ 19 | final class ForceValue 20 | { 21 | public function __construct(public readonly mixed $value) 22 | { 23 | } 24 | 25 | /** 26 | * @param array $what 27 | * @return array 28 | */ 29 | public static function unwrap(mixed $what): mixed 30 | { 31 | if (\is_array($what)) { 32 | return \array_map( 33 | self::unwrap(...), 34 | $what 35 | ); 36 | } 37 | 38 | return $what instanceof self ? $what->value : $what; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /config/in_memory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\DependencyInjection\Loader\Configurator; 13 | 14 | use Zenstruck\Foundry\InMemory\InMemoryFactoryRegistry; 15 | use Zenstruck\Foundry\InMemory\InMemoryRepositoryRegistry; 16 | 17 | return static function(ContainerConfigurator $container): void { 18 | $container->services() 19 | ->set('.zenstruck_foundry.in_memory.factory_registry', InMemoryFactoryRegistry::class) 20 | ->decorate('.zenstruck_foundry.factory_registry') 21 | ->arg('$decorated', service('.inner')); 22 | 23 | $container->services() 24 | ->set('.zenstruck_foundry.in_memory.repository_registry', InMemoryRepositoryRegistry::class) 25 | ->arg('$inMemoryRepositories', abstract_arg('inMemoryRepositories')) 26 | ; 27 | }; 28 | -------------------------------------------------------------------------------- /src/InMemory/AsInMemoryTest.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\InMemory; 15 | 16 | /** 17 | * @author Nicolas PHILIPPE 18 | * @experimental 19 | */ 20 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] 21 | final class AsInMemoryTest 22 | { 23 | /** 24 | * @param class-string $class 25 | * @internal 26 | */ 27 | public static function shouldEnableInMemory(string $class, string $method): bool 28 | { 29 | $classReflection = new \ReflectionClass($class); 30 | 31 | if ($classReflection->getAttributes(static::class)) { 32 | return true; 33 | } 34 | 35 | return (bool) $classReflection->getMethod($method)->getAttributes(static::class); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.roave-backward-compatibility-check.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | #\[BC\] SKIPPED: Roave\\BetterReflection\\Reflection\\ReflectionClass "Rector\\Rector\\AbstractRector" could not be found in the located source# 7 | #\[BC\] SKIPPED: Roave\\BetterReflection\\Reflection\\ReflectionClass "Psalm\\Plugin\\PluginEntryPointInterface" could not be found in the located source# 8 | #\[BC\] SKIPPED: Roave\\BetterReflection\\Reflection\\ReflectionClass "Psalm\\Plugin\\EventHandler\\AfterMethodCallAnalysisInterface" could not be found in the located source# 9 | #(.*)Zenstruck\\Foundry\\Utils\\Rector(.*)# 10 | #(.*)initializeInternal(.*)# 11 | 12 | 13 | -------------------------------------------------------------------------------- /config/mongo.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\DependencyInjection\Loader\Configurator; 13 | 14 | use Zenstruck\Foundry\Mongo\MongoPersistenceStrategy; 15 | use Zenstruck\Foundry\Mongo\MongoResetter; 16 | use Zenstruck\Foundry\Mongo\MongoSchemaResetter; 17 | 18 | return static function(ContainerConfigurator $container): void { 19 | $container->services() 20 | ->set('.zenstruck_foundry.persistence_strategy.mongo', MongoPersistenceStrategy::class) 21 | ->args([ 22 | service('doctrine_mongodb'), 23 | ]) 24 | ->tag('.foundry.persistence_strategy') 25 | 26 | ->set(MongoResetter::class, MongoSchemaResetter::class) 27 | ->args([ 28 | abstract_arg('managers'), 29 | ]) 30 | ->tag('.foundry.persistence.schema_resetter') 31 | ; 32 | }; 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kevin Bond 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Command/StubCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Command; 13 | 14 | use Symfony\Component\Console\Command\Command; 15 | use Symfony\Component\Console\Input\InputInterface; 16 | use Symfony\Component\Console\Output\OutputInterface; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @internal 22 | */ 23 | final class StubCommand extends Command 24 | { 25 | public function __construct(private string $message) 26 | { 27 | parent::__construct(); 28 | } 29 | 30 | protected function configure(): void 31 | { 32 | $this->ignoreValidationErrors(); 33 | } 34 | 35 | /** 36 | * @throws \RuntimeException 37 | */ 38 | protected function execute(InputInterface $input, OutputInterface $output): int 39 | { 40 | throw new \RuntimeException($this->message); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Maker/Factory/AbstractDoctrineDefaultPropertiesGuesser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker\Factory; 13 | 14 | use Doctrine\Persistence\Mapping\ClassMetadata; 15 | use Zenstruck\Foundry\Persistence\PersistenceManager; 16 | 17 | /** 18 | * @internal 19 | */ 20 | abstract class AbstractDoctrineDefaultPropertiesGuesser extends AbstractDefaultPropertyGuesser 21 | { 22 | public function __construct(protected PersistenceManager $persistenceManager, FactoryClassMap $factoryClassMap, FactoryGenerator $factoryGenerator) 23 | { 24 | parent::__construct($factoryClassMap, $factoryGenerator); 25 | } 26 | 27 | protected function getClassMetadata(MakeFactoryData $makeFactoryData): ClassMetadata 28 | { 29 | $class = $makeFactoryData->getObjectFullyQualifiedClassName(); 30 | 31 | return $this->persistenceManager->metadataFor($class); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Object/Event/AfterInstantiate.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Object\Event; 15 | 16 | use Zenstruck\Foundry\Factory; 17 | use Zenstruck\Foundry\ObjectFactory; 18 | 19 | /** 20 | * @author Nicolas PHILIPPE 21 | * 22 | * @template T of object 23 | * @implements Event 24 | * 25 | * @phpstan-import-type Parameters from Factory 26 | */ 27 | final class AfterInstantiate implements Event 28 | { 29 | public function __construct( 30 | /** @var T */ 31 | public readonly object $object, 32 | /** @phpstan-var Parameters */ 33 | public readonly array $parameters, 34 | /** @var ObjectFactory */ 35 | public readonly ObjectFactory $factory, 36 | ) { 37 | } 38 | 39 | public function objectClassName(): string 40 | { 41 | return $this->object::class; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Object/Event/BeforeInstantiate.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Object\Event; 15 | 16 | use Zenstruck\Foundry\Factory; 17 | use Zenstruck\Foundry\ObjectFactory; 18 | 19 | /** 20 | * @author Nicolas PHILIPPE 21 | * 22 | * @template T of object 23 | * @implements Event 24 | * 25 | * @phpstan-import-type Parameters from Factory 26 | */ 27 | final class BeforeInstantiate implements Event 28 | { 29 | public function __construct( 30 | /** @phpstan-var Parameters */ 31 | public array $parameters, 32 | /** @var class-string */ 33 | public readonly string $objectClass, 34 | /** @var ObjectFactory */ 35 | public readonly ObjectFactory $factory, 36 | ) { 37 | } 38 | 39 | public function objectClassName(): string 40 | { 41 | return $this->objectClass; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/FactoryRegistry.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry; 13 | 14 | use Zenstruck\Foundry\Exception\CannotCreateFactory; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @internal 20 | */ 21 | final class FactoryRegistry implements FactoryRegistryInterface 22 | { 23 | /** 24 | * @param Factory[] $factories 25 | */ 26 | public function __construct(private iterable $factories) 27 | { 28 | } 29 | 30 | public function get(string $class): Factory 31 | { 32 | foreach ($this->factories as $factory) { 33 | if ($class === $factory::class) { 34 | return $factory; // @phpstan-ignore return.type 35 | } 36 | } 37 | 38 | try { 39 | return new $class(); 40 | } catch (\ArgumentCountError $e) { 41 | throw CannotCreateFactory::argumentCountError($e); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/symfony_console.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry; 13 | 14 | use Symfony\Bundle\FrameworkBundle\Console\Application; 15 | use Symfony\Component\Console\Input\StringInput; 16 | use Symfony\Component\Console\Output\BufferedOutput; 17 | use Symfony\Component\HttpKernel\KernelInterface; 18 | 19 | /** 20 | * @internal 21 | */ 22 | function runCommand(Application $application, string $command, bool $canFail = false): void 23 | { 24 | $exit = $application->run(new StringInput("{$command} --no-interaction"), $output = new BufferedOutput()); 25 | 26 | if (0 !== $exit && !$canFail) { 27 | throw new \RuntimeException(\sprintf('Error running "%s": %s', $command, $output->fetch())); 28 | } 29 | } 30 | 31 | /** 32 | * @internal 33 | */ 34 | function application(KernelInterface $kernel): Application 35 | { 36 | $application = new Application($kernel); 37 | $application->setAutoExit(false); 38 | 39 | return $application; 40 | } 41 | -------------------------------------------------------------------------------- /src/Persistence/Event/AfterPersist.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Persistence\Event; 15 | 16 | use Zenstruck\Foundry\Factory; 17 | use Zenstruck\Foundry\Object\Event\Event; 18 | use Zenstruck\Foundry\Persistence\PersistentObjectFactory; 19 | 20 | /** 21 | * @author Nicolas PHILIPPE 22 | * 23 | * @template T of object 24 | * @implements Event 25 | * 26 | * @phpstan-import-type Parameters from Factory 27 | */ 28 | final class AfterPersist implements Event 29 | { 30 | public function __construct( 31 | /** @var T */ 32 | public readonly object $object, 33 | /** @phpstan-var Parameters */ 34 | public readonly array $parameters, 35 | /** @var PersistentObjectFactory */ 36 | public readonly PersistentObjectFactory $factory, 37 | ) { 38 | } 39 | 40 | public function objectClassName(): string 41 | { 42 | return $this->object::class; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/PHPUnit/BootFoundryOnDataProviderMethodCalled.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\PHPUnit; 15 | 16 | use PHPUnit\Event; 17 | use Zenstruck\Foundry\Configuration; 18 | use Zenstruck\Foundry\InMemory\AsInMemoryTest; 19 | 20 | /** 21 | * @internal 22 | * @author Nicolas PHILIPPE 23 | */ 24 | final class BootFoundryOnDataProviderMethodCalled implements Event\Test\DataProviderMethodCalledSubscriber 25 | { 26 | public function notify(Event\Test\DataProviderMethodCalled $event): void 27 | { 28 | if (\method_exists($event->testMethod()->className(), '_bootForDataProvider')) { 29 | $event->testMethod()->className()::_bootForDataProvider(); 30 | } 31 | 32 | $testMethod = $event->testMethod(); 33 | 34 | if (AsInMemoryTest::shouldEnableInMemory($testMethod->className(), $testMethod->methodName())) { 35 | Configuration::instance()->enableInMemory(); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/InMemory/InMemoryRepositoryTrait.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\InMemory; 15 | 16 | /** 17 | * @template T of object 18 | * @phpstan-require-implements InMemoryRepository 19 | * @experimental 20 | * 21 | * @author Nicolas PHILIPPE 22 | */ 23 | trait InMemoryRepositoryTrait 24 | { 25 | /** 26 | * @var list 27 | */ 28 | private array $items = []; 29 | 30 | /** 31 | * @param T $item 32 | */ 33 | public function _save(object $item): void 34 | { 35 | if (!\is_a($item, self::_class(), allow_string: true)) { 36 | throw new \InvalidArgumentException(\sprintf('Given object of class "%s" is not an instance of expected "%s"', $item::class, self::_class())); 37 | } 38 | 39 | if (!\in_array($item, $this->items, true)) { 40 | $this->items[] = $item; 41 | } 42 | } 43 | 44 | /** 45 | * @return list 46 | */ 47 | public function _all(): array 48 | { 49 | return $this->items; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Mongo/MongoSchemaResetter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Mongo; 15 | 16 | use Symfony\Component\HttpKernel\KernelInterface; 17 | 18 | use function Zenstruck\Foundry\application; 19 | use function Zenstruck\Foundry\runCommand; 20 | 21 | /** 22 | * @internal 23 | * @author Nicolas PHILIPPE 24 | */ 25 | final class MongoSchemaResetter implements MongoResetter 26 | { 27 | /** 28 | * @param list $managers 29 | */ 30 | public function __construct(private array $managers) 31 | { 32 | } 33 | 34 | public function resetBeforeEachTest(KernelInterface $kernel): void 35 | { 36 | $application = application($kernel); 37 | 38 | foreach ($this->managers as $manager) { 39 | try { 40 | runCommand($application, "doctrine:mongodb:schema:drop --dm={$manager}"); 41 | } catch (\Exception) { 42 | } 43 | 44 | runCommand($application, "doctrine:mongodb:schema:create --dm={$manager}"); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Object/Event/HookListenerFilter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Object\Event; 15 | 16 | final class HookListenerFilter 17 | { 18 | /** @var \Closure(Event): void */ 19 | private \Closure $listener; 20 | 21 | /** 22 | * @param array{0: object, 1: string} $listener 23 | * @param class-string|null $objectClass 24 | */ 25 | public function __construct(array $listener, private ?string $objectClass = null) 26 | { 27 | if (!\is_callable($listener)) { 28 | throw new \InvalidArgumentException(\sprintf('Listener must be a callable, "%s" given.', \get_debug_type($listener))); 29 | } 30 | 31 | $this->listener = $listener(...); 32 | } 33 | 34 | /** 35 | * @param Event $event 36 | */ 37 | public function __invoke(Event $event): void 38 | { 39 | if ($this->objectClass && $event->objectClassName() !== $this->objectClass) { 40 | return; 41 | } 42 | 43 | ($this->listener)($event); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/PHPUnit/EnableInMemoryBeforeTest.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\PHPUnit; 15 | 16 | use PHPUnit\Event; 17 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 18 | use Zenstruck\Foundry\Configuration; 19 | use Zenstruck\Foundry\InMemory\AsInMemoryTest; 20 | use Zenstruck\Foundry\InMemory\CannotEnableInMemory; 21 | 22 | final class EnableInMemoryBeforeTest implements Event\Test\PreparedSubscriber 23 | { 24 | public function notify(Event\Test\Prepared $event): void 25 | { 26 | $test = $event->test(); 27 | 28 | if (!$test instanceof Event\Code\TestMethod) { 29 | return; 30 | } 31 | 32 | $testClass = $test->className(); 33 | 34 | if (!AsInMemoryTest::shouldEnableInMemory($testClass, $test->methodName())) { 35 | return; 36 | } 37 | 38 | if (!\is_subclass_of($testClass, KernelTestCase::class)) { 39 | throw CannotEnableInMemory::testIsNotAKernelTestCase("{$test->className()}::{$test->methodName()}"); 40 | } 41 | 42 | Configuration::instance()->enableInMemory(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /config/command_stubs.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\DependencyInjection\Loader\Configurator; 13 | 14 | use Zenstruck\Foundry\Command\StubCommand; 15 | 16 | return static function(ContainerConfigurator $container): void { 17 | $container->services() 18 | ->set('.zenstruck_foundry.make_factory_command', StubCommand::class) 19 | ->args([ 20 | "To run \"make:factory\" you need the \"MakerBundle\" which is currently not installed.\n\nTry running \"composer require symfony/maker-bundle --dev\".", 21 | ]) 22 | ->tag('console.command', [ 23 | 'command' => '|make:factory', 24 | 'description' => 'Creates a Foundry object factory', 25 | ]) 26 | ->set('.zenstruck_foundry.make_story_command', StubCommand::class) 27 | ->args([ 28 | "To run \"make:story\" you need the \"MakerBundle\" which is currently not installed.\n\nTry running \"composer require symfony/maker-bundle --dev\".", 29 | ]) 30 | ->tag('console.command', [ 31 | 'command' => '|make:story', 32 | 'description' => 'Creates a Foundry story', 33 | ]) 34 | ; 35 | }; 36 | -------------------------------------------------------------------------------- /src/InMemory/InMemoryRepositoryRegistry.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\InMemory; 15 | 16 | use Symfony\Component\DependencyInjection\ServiceLocator; 17 | 18 | /** 19 | * @internal 20 | * @author Nicolas PHILIPPE 21 | */ 22 | final class InMemoryRepositoryRegistry 23 | { 24 | /** 25 | * @var array> 26 | */ 27 | private array $genericInMemoryRepositories = []; 28 | 29 | public function __construct( 30 | /** @var ServiceLocator> */ 31 | private readonly ServiceLocator $inMemoryRepositories, 32 | ) { 33 | } 34 | 35 | /** 36 | * @template T of object 37 | * 38 | * @param class-string $class 39 | * 40 | * @return InMemoryRepository 41 | */ 42 | public function get(string $class): InMemoryRepository 43 | { 44 | if (!$this->inMemoryRepositories->has($class)) { 45 | return $this->genericInMemoryRepositories[$class] ??= new GenericInMemoryRepository($class); // @phpstan-ignore return.type 46 | } 47 | 48 | return $this->inMemoryRepositories->get($class); // @phpstan-ignore return.type 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/PHPUnit/FoundryExtension.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\PHPUnit; 15 | 16 | use PHPUnit\Metadata\Version\ConstraintRequirement; 17 | use PHPUnit\Runner; 18 | use PHPUnit\TextUI; 19 | use Zenstruck\Foundry\Configuration; 20 | 21 | /** 22 | * @internal 23 | * @author Nicolas PHILIPPE 24 | */ 25 | final class FoundryExtension implements Runner\Extension\Extension 26 | { 27 | public function bootstrap( 28 | TextUI\Configuration\Configuration $configuration, 29 | Runner\Extension\Facade $facade, 30 | Runner\Extension\ParameterCollection $parameters, 31 | ): void { 32 | // shutdown Foundry if for some reason it has been booted before 33 | if (Configuration::isBooted()) { 34 | Configuration::shutdown(); 35 | } 36 | 37 | $subscribers = [ 38 | new BuildStoryOnTestPrepared(), 39 | new EnableInMemoryBeforeTest(), 40 | new DisplayFakerSeedOnTestSuiteFinished(), 41 | ]; 42 | 43 | if (ConstraintRequirement::from('>=11.4')->isSatisfiedBy(Runner\Version::id())) { 44 | // those deal with data provider events which can be useful only if PHPUnit >=11.4 is used 45 | $subscribers[] = new BootFoundryOnDataProviderMethodCalled(); 46 | $subscribers[] = new ShutdownFoundryOnDataProviderMethodFinished(); 47 | } 48 | 49 | $facade->registerSubscribers(...$subscribers); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/InMemory/GenericInMemoryRepository.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\InMemory; 15 | 16 | /** 17 | * @template T of object 18 | * @implements InMemoryRepository 19 | * @author Nicolas PHILIPPE 20 | * @experimental 21 | * 22 | * This class will be used when a specific "in-memory" repository does not exist for a given class. 23 | */ 24 | final class GenericInMemoryRepository implements InMemoryRepository 25 | { 26 | /** 27 | * @var list 28 | */ 29 | private array $elements = []; 30 | 31 | /** 32 | * @param class-string $class 33 | */ 34 | public function __construct( 35 | private readonly string $class, 36 | ) { 37 | } 38 | 39 | /** 40 | * @param T $item 41 | */ 42 | public function _save(object $item): void 43 | { 44 | if (!$item instanceof $this->class) { 45 | throw new \InvalidArgumentException(\sprintf('Given object of class "%s" is not an instance of expected "%s"', $item::class, $this->class)); 46 | } 47 | 48 | if (!\in_array($item, $this->elements, true)) { 49 | $this->elements[] = $item; 50 | } 51 | } 52 | 53 | public function _all(): array 54 | { 55 | return $this->elements; 56 | } 57 | 58 | public static function _class(): string 59 | { 60 | throw new \BadMethodCallException('This method should not be called on a GenericInMemoryRepository.'); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ORM/ResetDatabase/SchemaDatabaseResetter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\ORM\ResetDatabase; 15 | 16 | use Symfony\Bundle\FrameworkBundle\Console\Application; 17 | use Symfony\Component\HttpKernel\KernelInterface; 18 | 19 | use function Zenstruck\Foundry\application; 20 | use function Zenstruck\Foundry\runCommand; 21 | 22 | /** 23 | * @internal 24 | * @author Nicolas PHILIPPE 25 | */ 26 | final class SchemaDatabaseResetter extends BaseOrmResetter 27 | { 28 | public function resetBeforeFirstTest(KernelInterface $kernel): void 29 | { 30 | $application = application($kernel); 31 | 32 | $this->dropAndResetDatabase($application); 33 | $this->createSchema($application); 34 | } 35 | 36 | protected function doResetBeforeEachTest(KernelInterface $kernel): void 37 | { 38 | $application = application($kernel); 39 | 40 | $this->dropSchema($application); 41 | $this->createSchema($application); 42 | } 43 | 44 | private function createSchema(Application $application): void 45 | { 46 | foreach ($this->managers as $manager) { 47 | runCommand($application, "doctrine:schema:update --em={$manager} --force -v"); 48 | } 49 | } 50 | 51 | private function dropSchema(Application $application): void 52 | { 53 | foreach ($this->managers as $manager) { 54 | runCommand($application, "doctrine:schema:drop --em={$manager} --force --full-database"); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Test/UnitTestConfig.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Test; 13 | 14 | use Faker; 15 | use Zenstruck\Foundry\Configuration; 16 | use Zenstruck\Foundry\FactoryRegistry; 17 | use Zenstruck\Foundry\Object\Instantiator; 18 | use Zenstruck\Foundry\ObjectFactory; 19 | use Zenstruck\Foundry\StoryRegistry; 20 | 21 | /** 22 | * @author Kevin Bond 23 | * 24 | * @phpstan-import-type InstantiatorCallable from ObjectFactory 25 | */ 26 | final class UnitTestConfig 27 | { 28 | /** @phpstan-var InstantiatorCallable|null */ 29 | private static $instantiator; 30 | private static ?Faker\Generator $faker = null; 31 | 32 | /** 33 | * @phpstan-param InstantiatorCallable|null $instantiator 34 | */ 35 | public static function configure(Instantiator|callable|null $instantiator = null, ?Faker\Generator $faker = null): void 36 | { 37 | self::$instantiator = $instantiator; 38 | self::$faker = $faker; 39 | } 40 | 41 | /** 42 | * @internal 43 | */ 44 | public static function build(): Configuration 45 | { 46 | $faker = self::$faker ?? Faker\Factory::create(); 47 | $faker->unique(true); 48 | 49 | return new Configuration( 50 | new FactoryRegistry([]), 51 | $faker, 52 | self::$instantiator ?? Instantiator::withConstructor(), 53 | new StoryRegistry([]), 54 | forcedFakerSeed: $_SERVER['FOUNDRY_FAKER_SEED'] ?? $_ENV['FOUNDRY_FAKER_SEED'] ?? (\getenv('FOUNDRY_FAKER_SEED') ?: null) 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/InMemory/InMemoryFactoryRegistry.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\InMemory; 15 | 16 | use Zenstruck\Foundry\Configuration; 17 | use Zenstruck\Foundry\Factory; 18 | use Zenstruck\Foundry\FactoryRegistryInterface; 19 | use Zenstruck\Foundry\ObjectFactory; 20 | use Zenstruck\Foundry\Persistence\PersistentObjectFactory; 21 | 22 | /** 23 | * @internal 24 | * @author Nicolas PHILIPPE 25 | */ 26 | final class InMemoryFactoryRegistry implements FactoryRegistryInterface 27 | { 28 | public function __construct( 29 | private readonly FactoryRegistryInterface $decorated, 30 | ) { 31 | } 32 | 33 | /** 34 | * @template T of Factory 35 | * 36 | * @param class-string $class 37 | * 38 | * @return T 39 | */ 40 | public function get(string $class): Factory 41 | { 42 | $factory = $this->decorated->get($class); 43 | 44 | if (!$factory instanceof ObjectFactory || !Configuration::instance()->isInMemoryEnabled()) { 45 | return $factory; 46 | } 47 | 48 | if ($factory instanceof PersistentObjectFactory) { 49 | $factory = $factory->withoutPersisting(); 50 | } 51 | 52 | return $factory // @phpstan-ignore argument.templateType 53 | ->afterInstantiate( 54 | function(object $object) use ($factory) { 55 | Configuration::instance()->inMemoryRepositoryRegistry?->get($factory::class())->_save($object); 56 | } 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Maker/Factory/AbstractDefaultPropertyGuesser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker\Factory; 13 | 14 | use Symfony\Bundle\MakerBundle\Str; 15 | use Symfony\Component\Console\Style\SymfonyStyle; 16 | 17 | /** 18 | * @internal 19 | */ 20 | abstract class AbstractDefaultPropertyGuesser implements DefaultPropertiesGuesser 21 | { 22 | public function __construct(private FactoryClassMap $factoryClassMap, private FactoryGenerator $factoryGenerator) 23 | { 24 | } 25 | 26 | /** @param class-string $fieldClass */ 27 | protected function addDefaultValueUsingFactory(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery, string $fieldName, string $fieldClass): void 28 | { 29 | if (!$factoryClass = $this->factoryClassMap->getFactoryForClass($fieldClass)) { 30 | if ($makeFactoryQuery->generateAllFactories() || $io->confirm( 31 | "A factory for class \"{$fieldClass}\" is missing for field {$makeFactoryData->getObjectShortName()}::\${$fieldName}. Do you want to create it?", 32 | )) { 33 | $factoryClass = $this->factoryGenerator->generateFactory($io, $makeFactoryQuery->withClass($fieldClass)); 34 | } else { 35 | $makeFactoryData->addDefaultProperty(\lcfirst($fieldName), "null, // TODO add {$fieldClass} type manually"); 36 | 37 | return; 38 | } 39 | } 40 | 41 | $makeFactoryData->addUse($factoryClass); 42 | 43 | $factoryShortName = Str::getShortClassName($factoryClass); 44 | $makeFactoryData->addDefaultProperty(\lcfirst($fieldName), "{$factoryShortName}::new(),"); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/AnonymousFactoryGenerator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry; 13 | 14 | /** 15 | * @author Kevin Bond 16 | * 17 | * @internal 18 | */ 19 | final class AnonymousFactoryGenerator 20 | { 21 | /** 22 | * @template T of object 23 | * @template F of Factory 24 | * 25 | * @param class-string $class 26 | * @param class-string $factoryClass 27 | * 28 | * @return class-string 29 | */ 30 | public static function create(string $class, string $factoryClass): string 31 | { 32 | $anonymousClassName = \sprintf('FoundryAnonymous%s_', (new \ReflectionClass($factoryClass))->getShortName()); 33 | $anonymousClassName .= \str_replace('\\', '', $class); 34 | $anonymousClassName = \preg_replace('/\W/', '', $anonymousClassName); // sanitize for anonymous classes 35 | 36 | /** @var class-string $anonymousClassName */ 37 | if (!\class_exists($anonymousClassName)) { 38 | $anonymousClassCode = << 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\ORM\ResetDatabase; 15 | 16 | use Doctrine\Bundle\DoctrineBundle\Registry; 17 | use Symfony\Component\HttpKernel\KernelInterface; 18 | 19 | use function Zenstruck\Foundry\application; 20 | use function Zenstruck\Foundry\runCommand; 21 | 22 | /** 23 | * @internal 24 | * @author Nicolas PHILIPPE 25 | */ 26 | final class MigrateDatabaseResetter extends BaseOrmResetter 27 | { 28 | /** 29 | * @param list $configurations 30 | */ 31 | public function __construct( 32 | private readonly array $configurations, 33 | Registry $registry, 34 | array $managers, 35 | array $connections, 36 | ) { 37 | parent::__construct($registry, $managers, $connections); 38 | } 39 | 40 | public function resetBeforeFirstTest(KernelInterface $kernel): void 41 | { 42 | $this->resetWithMigration($kernel); 43 | } 44 | 45 | public function doResetBeforeEachTest(KernelInterface $kernel): void 46 | { 47 | $this->resetWithMigration($kernel); 48 | } 49 | 50 | private function resetWithMigration(KernelInterface $kernel): void 51 | { 52 | $application = application($kernel); 53 | 54 | $this->dropAndResetDatabase($application); 55 | 56 | if (!$this->configurations) { 57 | runCommand($application, 'doctrine:migrations:migrate'); 58 | 59 | return; 60 | } 61 | 62 | foreach ($this->configurations as $configuration) { 63 | runCommand($application, "doctrine:migrations:migrate --configuration={$configuration}"); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Test/ResetDatabase.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Test; 13 | 14 | use PHPUnit\Framework\Attributes\Before; 15 | use PHPUnit\Framework\Attributes\BeforeClass; 16 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 17 | use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager; 18 | 19 | /** 20 | * @author Kevin Bond 21 | */ 22 | trait ResetDatabase 23 | { 24 | /** 25 | * @internal 26 | * @beforeClass 27 | */ 28 | #[BeforeClass] 29 | public static function _resetDatabaseBeforeFirstTest(): void 30 | { 31 | if (!\is_subclass_of(static::class, KernelTestCase::class)) { // @phpstan-ignore function.alreadyNarrowedType 32 | throw new \RuntimeException(\sprintf('The "%s" trait can only be used on TestCases that extend "%s".', __TRAIT__, KernelTestCase::class)); 33 | } 34 | 35 | ResetDatabaseManager::resetBeforeFirstTest( 36 | static fn() => static::bootKernel(), 37 | static fn() => static::ensureKernelShutdown(), 38 | ); 39 | } 40 | 41 | /** 42 | * @internal 43 | * @before 44 | */ 45 | #[Before] 46 | public static function _resetDatabaseBeforeEachTest(): void 47 | { 48 | if (!\is_subclass_of(static::class, KernelTestCase::class)) { // @phpstan-ignore function.alreadyNarrowedType 49 | throw new \RuntimeException(\sprintf('The "%s" trait can only be used on TestCases that extend "%s".', __TRAIT__, KernelTestCase::class)); 50 | } 51 | 52 | ResetDatabaseManager::resetBeforeEachTest( 53 | static fn() => static::bootKernel(), 54 | static fn() => static::ensureKernelShutdown(), 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /config/orm.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\DependencyInjection\Loader\Configurator; 13 | 14 | use DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver; 15 | use Zenstruck\Foundry\ORM\DoctrineOrmVersionGuesser; 16 | use Zenstruck\Foundry\ORM\OrmV2PersistenceStrategy; 17 | use Zenstruck\Foundry\ORM\OrmV3PersistenceStrategy; 18 | use Zenstruck\Foundry\ORM\ResetDatabase\BaseOrmResetter; 19 | use Zenstruck\Foundry\ORM\ResetDatabase\DamaDatabaseResetter; 20 | use Zenstruck\Foundry\ORM\ResetDatabase\OrmResetter; 21 | 22 | return static function(ContainerConfigurator $container): void { 23 | $container->services() 24 | ->set('.zenstruck_foundry.persistence_strategy.orm', DoctrineOrmVersionGuesser::isOrmV3() ? OrmV3PersistenceStrategy::class : OrmV2PersistenceStrategy::class) 25 | ->args([ 26 | service('doctrine'), 27 | ]) 28 | ->tag('.foundry.persistence_strategy') 29 | 30 | ->set('.zenstruck_foundry.persistence.database_resetter.orm.abstract', BaseOrmResetter::class) 31 | ->arg('$registry', service('doctrine')) 32 | ->arg('$managers', service('managers')) 33 | ->arg('$connections', service('connections')) 34 | ->abstract() 35 | 36 | ->set(OrmResetter::class/* class to be defined thanks to the configuration */) 37 | ->parent('.zenstruck_foundry.persistence.database_resetter.orm.abstract') 38 | ->tag('.foundry.persistence.database_resetter') 39 | ->tag('.foundry.persistence.schema_resetter') 40 | ; 41 | 42 | if (\class_exists(StaticDriver::class)) { 43 | $container->services() 44 | ->set('.zenstruck_foundry.persistence.database_resetter.orm.dama', DamaDatabaseResetter::class) 45 | ->decorate(OrmResetter::class, priority: 10) 46 | ->args([ 47 | service('.inner'), 48 | ]) 49 | ; 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\DependencyInjection\Loader\Configurator; 13 | 14 | use Faker; 15 | use Zenstruck\Foundry\Configuration; 16 | use Zenstruck\Foundry\FactoryRegistry; 17 | use Zenstruck\Foundry\Object\Instantiator; 18 | use Zenstruck\Foundry\StoryRegistry; 19 | 20 | return static function(ContainerConfigurator $container): void { 21 | $container->services() 22 | ->set('.zenstruck_foundry.faker', Faker\Generator::class) 23 | ->factory([Faker\Factory::class, 'create']) 24 | 25 | ->set('.zenstruck_foundry.factory_registry', FactoryRegistry::class) 26 | ->args([tagged_iterator('foundry.factory')]) 27 | 28 | ->set('.zenstruck_foundry.story_registry', StoryRegistry::class) 29 | ->args([ 30 | tagged_iterator('foundry.story'), 31 | abstract_arg('global_stories'), 32 | ]) 33 | 34 | ->set('.zenstruck_foundry.instantiator', Instantiator::class) 35 | ->factory([Instantiator::class, 'withConstructor']) 36 | 37 | ->set('.zenstruck_foundry.configuration', Configuration::class) 38 | ->args([ 39 | service('.zenstruck_foundry.factory_registry'), 40 | service('.zenstruck_foundry.faker'), 41 | service('.zenstruck_foundry.instantiator'), 42 | service('.zenstruck_foundry.story_registry'), 43 | service('.zenstruck_foundry.persistence_manager')->nullOnInvalid(), 44 | param('zenstruck_foundry.persistence.flush_once'), 45 | '%env(default:zenstruck_foundry.faker.seed:int:FOUNDRY_FAKER_SEED)%', 46 | service('.zenstruck_foundry.in_memory.repository_registry'), 47 | service('.foundry.persistence.objects_tracker')->nullOnInvalid(), 48 | param('zenstruck_foundry.enable_auto_refresh_with_lazy_objects'), 49 | service('event_dispatcher')->nullOnInvalid(), 50 | ]) 51 | ->public() 52 | ; 53 | }; 54 | -------------------------------------------------------------------------------- /src/LazyValue.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class LazyValue 18 | { 19 | /** @var \Closure():mixed */ 20 | private \Closure $factory; 21 | private mixed $memoizedValue = null; 22 | 23 | /** 24 | * @param callable():mixed $factory 25 | */ 26 | private function __construct(callable $factory, private bool $memoize = false) 27 | { 28 | $this->factory = $factory(...); 29 | } 30 | 31 | /** 32 | * @internal 33 | */ 34 | public function __invoke(): mixed 35 | { 36 | if ($this->memoize && isset($this->memoizedValue)) { 37 | return $this->memoizedValue; 38 | } 39 | 40 | $value = ($this->factory)(); 41 | 42 | if ($value instanceof self) { 43 | $value = ($value)(); 44 | } 45 | 46 | if (\is_array($value)) { 47 | $value = self::normalizeArray($value); 48 | } 49 | 50 | if ($this->memoize) { 51 | return $this->memoizedValue = $value; 52 | } 53 | 54 | return $value; 55 | } 56 | 57 | /** 58 | * @param callable():mixed $factory 59 | */ 60 | public static function new(callable $factory): self 61 | { 62 | return new self($factory, false); 63 | } 64 | 65 | /** 66 | * @param callable():mixed $factory 67 | */ 68 | public static function memoize(callable $factory): self 69 | { 70 | return new self($factory, true); 71 | } 72 | 73 | /** 74 | * @param array $value 75 | * @return array 76 | */ 77 | private static function normalizeArray(array $value): array 78 | { 79 | \array_walk_recursive($value, static function(mixed &$v): void { 80 | if ($v instanceof self) { 81 | $v = $v(); 82 | } 83 | }); 84 | 85 | return $value; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /utils/rector/src/RemoveWithoutAutorefreshCallRector.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Utils\Rector; 15 | 16 | use PhpParser\Node; 17 | use Rector\Rector\AbstractRector; 18 | 19 | final class RemoveWithoutAutorefreshCallRector extends AbstractRector 20 | { 21 | /** @return array> */ 22 | public function getNodeTypes(): array 23 | { 24 | return [Node\Stmt\Expression::class]; 25 | } 26 | 27 | /** 28 | * @param Node\Stmt\Expression $node 29 | * @return Node\Stmt\Expression|array|null 30 | */ 31 | public function refactor(Node $node): Node\Stmt\Expression|array|null 32 | { 33 | $method = $node->expr; 34 | 35 | if (!$method instanceof Node\Expr\MethodCall 36 | || $method->isFirstClassCallable() 37 | || !$method->var instanceof Node\Expr\Variable 38 | || !$this->isName($method->name, '_withoutAutoRefresh') 39 | || !isset($method->args[0]) 40 | || $method->args[0] instanceof Node\VariadicPlaceholder 41 | ) { 42 | return null; 43 | } 44 | 45 | $arg = $method->args[0]->value; 46 | 47 | if ($arg instanceof Node\Expr\Closure) { 48 | return $arg->stmts; 49 | } 50 | 51 | if ($arg instanceof Node\Expr\FuncCall && $arg->isFirstClassCallable()) { 52 | return new Node\Stmt\Expression( 53 | new Node\Expr\FuncCall( 54 | $arg->name, 55 | [new Node\Arg($method->var)] 56 | ) 57 | ); 58 | } 59 | 60 | if ($arg instanceof Node\Expr\MethodCall && $arg->isFirstClassCallable()) { 61 | return new Node\Stmt\Expression( 62 | new Node\Expr\MethodCall( 63 | $arg->var, 64 | $arg->name, 65 | [new Node\Arg($method->var)] 66 | ) 67 | ); 68 | } 69 | 70 | return null; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /utils/rector/src/MethodCallToFuncCallWithObjectAsFirstParameter/MethodCallToFuncCallWithObjectAsFirstParameterRector.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Utils\Rector\MethodCallToFuncCallWithObjectAsFirstParameter; 15 | 16 | use PhpParser\Node; 17 | use PhpParser\Node\Arg; 18 | use PhpParser\Node\Expr\FuncCall; 19 | use PhpParser\Node\Expr\MethodCall; 20 | use PhpParser\Node\Name\FullyQualified; 21 | use Rector\Contract\Rector\ConfigurableRectorInterface; 22 | use Rector\Rector\AbstractRector; 23 | 24 | final class MethodCallToFuncCallWithObjectAsFirstParameterRector extends AbstractRector implements ConfigurableRectorInterface 25 | { 26 | /** @var MethodCallToFuncCallWithObjectAsFirstParameter[] */ 27 | private array $methodCallsToFuncCalls = []; 28 | 29 | /** @return array> */ 30 | public function getNodeTypes(): array 31 | { 32 | return [MethodCall::class]; 33 | } 34 | 35 | /** @param MethodCall $node */ 36 | public function refactor(Node $node): ?Node 37 | { 38 | if ($node->isFirstClassCallable()) { 39 | return null; 40 | } 41 | 42 | foreach ($this->methodCallsToFuncCalls as $methodCallToFuncCall) { 43 | if (!$this->isName($node->name, $methodCallToFuncCall->methodName)) { 44 | continue; 45 | } 46 | 47 | return new FuncCall(new FullyQualified($methodCallToFuncCall->functionName), [new Arg($node->var), ...$node->getArgs()]); 48 | } 49 | 50 | return null; 51 | } 52 | 53 | /** @param mixed[] $configuration */ 54 | public function configure(array $configuration): void 55 | { 56 | foreach ($configuration as $configItem) { 57 | if (!$configItem instanceof MethodCallToFuncCallWithObjectAsFirstParameter) { 58 | throw new \InvalidArgumentException(\sprintf('Expected instance of "%s", got "%s".', MethodCallToFuncCallWithObjectAsFirstParameter::class, \get_debug_type($configItem))); 59 | } 60 | } 61 | $this->methodCallsToFuncCalls = $configuration; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/InMemory/DependencyInjection/InMemoryCompilerPass.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\InMemory\DependencyInjection; 15 | 16 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 17 | use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; 18 | use Symfony\Component\DependencyInjection\ContainerBuilder; 19 | use Symfony\Component\DependencyInjection\Reference; 20 | use Zenstruck\Foundry\InMemory\InMemoryRepository; 21 | 22 | /** 23 | * @internal 24 | * @author Nicolas PHILIPPE 25 | */ 26 | final class InMemoryCompilerPass implements CompilerPassInterface 27 | { 28 | public function process(ContainerBuilder $container): void 29 | { 30 | // create a service locator with all "in memory" repositories, indexed by target class 31 | $inMemoryRepositoriesServices = $container->findTaggedServiceIds('foundry.in_memory.repository'); 32 | $inMemoryRepositoriesLocator = ServiceLocatorTagPass::register( 33 | $container, 34 | \array_combine( 35 | \array_map( 36 | static function(string $serviceId) use ($container) { 37 | /** @var class-string> $inMemoryRepositoryClass */ 38 | $inMemoryRepositoryClass = $container->getDefinition($serviceId)->getClass() ?? throw new \LogicException("Service \"{$serviceId}\" should have a class."); 39 | 40 | return $inMemoryRepositoryClass::_class(); 41 | }, 42 | \array_keys($inMemoryRepositoriesServices) 43 | ), 44 | \array_map( 45 | static fn(string $inMemoryRepositoryId) => new Reference($inMemoryRepositoryId), 46 | \array_keys($inMemoryRepositoriesServices) 47 | ), 48 | ) 49 | ); 50 | 51 | $container->findDefinition('.zenstruck_foundry.in_memory.repository_registry') 52 | ->setArgument('$inMemoryRepositories', $inMemoryRepositoriesLocator) 53 | ; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Maker/Factory/ODMDefaultPropertiesGuesser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker\Factory; 13 | 14 | use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as ODMClassMetadata; 15 | use Symfony\Component\Console\Style\SymfonyStyle; 16 | use Zenstruck\Foundry\Persistence\Exception\NoPersistenceStrategy; 17 | 18 | /** 19 | * @internal 20 | */ 21 | class ODMDefaultPropertiesGuesser extends AbstractDoctrineDefaultPropertiesGuesser 22 | { 23 | public function __invoke(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery): void 24 | { 25 | $metadata = $this->getClassMetadata($makeFactoryData); 26 | 27 | if (!$metadata instanceof ODMClassMetadata) { 28 | throw new \InvalidArgumentException("\"{$makeFactoryData->getObjectFullyQualifiedClassName()}\" is not a valid ODM class."); 29 | } 30 | 31 | foreach ($metadata->associationMappings as $item) { 32 | // @phpstan-ignore-next-line 33 | if (!($item['embedded'] ?? false) || !($item['targetDocument'] ?? false)) { 34 | // foundry does not support ODM references 35 | continue; 36 | } 37 | 38 | /** @phpstan-ignore-next-line */ 39 | $isMultiple = ODMClassMetadata::MANY === $item['type']; 40 | if ($isMultiple) { 41 | continue; 42 | } 43 | 44 | $fieldName = $item['fieldName']; // @phpstan-ignore class.notFound 45 | 46 | if (!$makeFactoryQuery->isAllFields() && $item['nullable']) { // @phpstan-ignore class.notFound 47 | continue; 48 | } 49 | 50 | $this->addDefaultValueUsingFactory($io, $makeFactoryData, $makeFactoryQuery, $fieldName, $item['targetDocument']); // @phpstan-ignore class.notFound 51 | } 52 | } 53 | 54 | public function supports(MakeFactoryData $makeFactoryData): bool 55 | { 56 | try { 57 | $metadata = $this->getClassMetadata($makeFactoryData); 58 | 59 | return $metadata instanceof ODMClassMetadata; 60 | } catch (NoPersistenceStrategy) { 61 | return false; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/ORM/ResetDatabase/DamaDatabaseResetter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\ORM\ResetDatabase; 15 | 16 | use DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver; 17 | use Symfony\Component\HttpKernel\KernelInterface; 18 | use Zenstruck\Foundry\Configuration; 19 | use Zenstruck\Foundry\Persistence\PersistenceManager; 20 | use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager; 21 | 22 | /** 23 | * @internal 24 | * @author Nicolas PHILIPPE 25 | */ 26 | final class DamaDatabaseResetter implements OrmResetter 27 | { 28 | public function __construct( 29 | private OrmResetter $decorated, 30 | ) { 31 | } 32 | 33 | public function resetBeforeFirstTest(KernelInterface $kernel): void 34 | { 35 | $isDAMADoctrineTestBundleEnabled = ResetDatabaseManager::isDAMADoctrineTestBundleEnabled(); 36 | 37 | if (!$isDAMADoctrineTestBundleEnabled) { 38 | $this->decorated->resetBeforeFirstTest($kernel); 39 | 40 | return; 41 | } 42 | 43 | // disable static connections for this operation 44 | StaticDriver::setKeepStaticConnections(false); 45 | 46 | $this->decorated->resetBeforeFirstTest($kernel); 47 | 48 | if (PersistenceManager::isOrmOnly()) { 49 | // add global stories so they are available after transaction rollback 50 | Configuration::instance()->stories->loadGlobalStories(); 51 | } 52 | 53 | // shutdown kernel before re-enabling static connections 54 | // this would prevent any error if any ResetInterface execute sql queries (example: symfony/doctrine-messenger) 55 | $kernel->shutdown(); 56 | 57 | // re-enable static connections 58 | StaticDriver::setKeepStaticConnections(true); 59 | } 60 | 61 | public function resetBeforeEachTest(KernelInterface $kernel): void 62 | { 63 | if (ResetDatabaseManager::isDAMADoctrineTestBundleEnabled()) { 64 | // not required as the DAMADoctrineTestBundle wraps each test in a transaction 65 | return; 66 | } 67 | 68 | $this->decorated->resetBeforeEachTest($kernel); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/PHPUnit/BuildStoryOnTestPrepared.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\PHPUnit; 15 | 16 | use PHPUnit\Event; 17 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 18 | use Zenstruck\Foundry\Attribute\WithStory; 19 | use Zenstruck\Foundry\Exception\FactoriesTraitNotUsed; 20 | 21 | /** 22 | * @internal 23 | * @author Nicolas PHILIPPE 24 | */ 25 | final class BuildStoryOnTestPrepared implements Event\Test\PreparedSubscriber 26 | { 27 | public function notify(Event\Test\Prepared $event): void 28 | { 29 | $test = $event->test(); 30 | 31 | if (!$test->isTestMethod()) { 32 | return; 33 | } 34 | 35 | /** @var Event\Code\TestMethod $test */ 36 | $reflectionClass = new \ReflectionClass($test->className()); 37 | $withStoryAttributes = [ 38 | ...$this->collectWithStoryAttributesFromClassAndParents($reflectionClass), 39 | ...$reflectionClass->getMethod($test->methodName())->getAttributes(WithStory::class), 40 | ]; 41 | 42 | if (!$withStoryAttributes) { 43 | return; 44 | } 45 | 46 | if (!\is_subclass_of($test->className(), KernelTestCase::class)) { 47 | throw new \InvalidArgumentException(\sprintf('The test class "%s" must extend "%s" to use the "%s" attribute.', $test->className(), KernelTestCase::class, WithStory::class)); 48 | } 49 | 50 | FactoriesTraitNotUsed::throwIfClassDoesNotHaveFactoriesTrait($test->className()); 51 | 52 | foreach ($withStoryAttributes as $withStoryAttribute) { 53 | $withStoryAttribute->newInstance()->story::load(); 54 | } 55 | } 56 | 57 | /** 58 | * @return list<\ReflectionAttribute> 59 | */ 60 | private function collectWithStoryAttributesFromClassAndParents(\ReflectionClass $class): array // @phpstan-ignore missingType.generics 61 | { 62 | return [ 63 | ...$class->getAttributes(WithStory::class), 64 | ...( 65 | $class->getParentClass() 66 | ? $this->collectWithStoryAttributesFromClassAndParents($class->getParentClass()) 67 | : [] 68 | ), 69 | ]; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Persistence/Proxy.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Persistence; 13 | 14 | use Doctrine\Persistence\ObjectRepository; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @template T of object 20 | * @mixin T 21 | */ 22 | interface Proxy 23 | { 24 | /** 25 | * @phpstan-return static 26 | * @psalm-return T&Proxy 27 | */ 28 | public function _enableAutoRefresh(): static; 29 | 30 | /** 31 | * @phpstan-return static 32 | * @psalm-return T&Proxy 33 | */ 34 | public function _disableAutoRefresh(): static; 35 | 36 | /** 37 | * @param callable(static):void $callback 38 | * @phpstan-return static 39 | * @psalm-return T&Proxy 40 | */ 41 | public function _withoutAutoRefresh(callable $callback): static; 42 | 43 | /** 44 | * @phpstan-return static 45 | * @psalm-return T&Proxy 46 | */ 47 | public function _save(): static; 48 | 49 | /** 50 | * @phpstan-return static 51 | * @psalm-return T&Proxy 52 | */ 53 | public function _refresh(): static; 54 | 55 | /** 56 | * @phpstan-return static 57 | * @psalm-return T&Proxy 58 | */ 59 | public function _delete(): static; 60 | 61 | public function _get(string $property): mixed; 62 | 63 | /** 64 | * @phpstan-return static 65 | * @psalm-return T&Proxy 66 | */ 67 | public function _set(string $property, mixed $value): static; 68 | 69 | /** 70 | * @return T 71 | */ 72 | public function _real(bool $withAutoRefresh = true): object; 73 | 74 | /** 75 | * @phpstan-return static 76 | * @psalm-return T&Proxy 77 | */ 78 | public function _assertPersisted(string $message = '{entity} is not persisted.'): static; 79 | 80 | /** 81 | * @phpstan-return static 82 | * @psalm-return T&Proxy 83 | */ 84 | public function _assertNotPersisted(string $message = '{entity} is persisted but it should not be.'): static; 85 | 86 | /** 87 | * @return ProxyRepositoryDecorator> 88 | */ 89 | public function _repository(): ProxyRepositoryDecorator; 90 | 91 | /** 92 | * @internal 93 | */ 94 | public function _initializeLazyObject(): void; 95 | } 96 | -------------------------------------------------------------------------------- /src/Maker/Factory/FactoryClassMap.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker\Factory; 13 | 14 | use Zenstruck\Foundry\Factory; 15 | use Zenstruck\Foundry\Maker\Factory\Exception\FactoryClassAlreadyExistException; 16 | use Zenstruck\Foundry\ObjectFactory; 17 | 18 | /** 19 | * @internal 20 | */ 21 | final class FactoryClassMap 22 | { 23 | /** 24 | * @var array factory classes as keys, object class as values 25 | */ 26 | private array $classesWithFactories; 27 | 28 | /** @param \Traversable $factories */ 29 | public function __construct(\Traversable $factories) 30 | { 31 | $this->classesWithFactories = \array_unique( 32 | \array_reduce( 33 | \array_filter(\iterator_to_array($factories), static fn(Factory $f) => $f instanceof ObjectFactory), 34 | static function(array $carry, ObjectFactory $factory): array { 35 | $carry[$factory::class] = $factory::class(); 36 | 37 | return $carry; 38 | }, 39 | [], 40 | ), 41 | ); 42 | } 43 | 44 | /** @param class-string $class */ 45 | public function classHasFactory(string $class): bool 46 | { 47 | return \in_array($class, $this->classesWithFactories, true); 48 | } 49 | 50 | /** 51 | * @param class-string $class 52 | * 53 | * @return class-string|null 54 | */ 55 | public function getFactoryForClass(string $class): ?string 56 | { 57 | $factories = \array_flip($this->classesWithFactories); 58 | 59 | return $factories[$class] ?? null; 60 | } 61 | 62 | /** 63 | * @param class-string $factoryClass 64 | * @param class-string $class 65 | */ 66 | public function addFactoryForClass(string $factoryClass, string $class): void 67 | { 68 | if (\array_key_exists($factoryClass, $this->classesWithFactories)) { 69 | throw new FactoryClassAlreadyExistException($factoryClass); 70 | } 71 | 72 | $this->classesWithFactories[$factoryClass] = $class; 73 | } 74 | 75 | public function factoryClassExists(string $factoryClass): bool 76 | { 77 | return \array_key_exists($factoryClass, $this->classesWithFactories); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Maker/Factory/FactoryCandidatesClassesExtractor.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker\Factory; 13 | 14 | use Doctrine\ORM\Mapping\ClassMetadata as ORMClassMetadata; 15 | use Doctrine\Persistence\Mapping\ClassMetadata; 16 | use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; 17 | use Zenstruck\Foundry\Persistence\PersistenceManager; 18 | 19 | /** 20 | * @internal 21 | */ 22 | final class FactoryCandidatesClassesExtractor 23 | { 24 | public function __construct(private ?PersistenceManager $persistenceManager, private FactoryClassMap $factoryClassMap) 25 | { 26 | } 27 | 28 | /** 29 | * @return list 30 | */ 31 | public function factoryCandidatesClasses(): array 32 | { 33 | $choices = []; 34 | 35 | $embeddedClasses = []; 36 | 37 | foreach ($this->persistenceManager?->allMetadata() ?? [] as $metadata) { 38 | if ($metadata->getReflectionClass()->isAbstract()) { 39 | continue; 40 | } 41 | 42 | if (!$this->factoryClassMap->classHasFactory($metadata->getName())) { 43 | $choices[] = $metadata->getName(); 44 | } 45 | 46 | $embeddedClasses[] = $this->findEmbeddedClasses($metadata); 47 | } 48 | 49 | $choices = [ 50 | ...$choices, 51 | ...\array_values(\array_unique(\array_merge(...$embeddedClasses))), 52 | ]; 53 | 54 | \sort($choices); 55 | 56 | if (empty($choices)) { 57 | throw new RuntimeCommandException('No entities or documents found, or none left to make factories for.'); 58 | } 59 | 60 | return $choices; 61 | } 62 | 63 | /** 64 | * @return list 65 | */ 66 | private function findEmbeddedClasses(ClassMetadata $metadata): array 67 | { 68 | // - Doctrine ORM embedded objects does NOT have metadata classes, so we have to find all embedded classes inside entities 69 | // - Doctrine ODM embedded objects HAVE metadata classes, so they are already returned by factoryCandidatesClasses() 70 | return match (true) { 71 | $metadata instanceof ORMClassMetadata => \array_column($metadata->embeddedClasses, 'class'), 72 | default => [], 73 | }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/DependencyInjection/AsFixtureStoryCompilerPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\DependencyInjection; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Exception\LogicException; 17 | use Symfony\Component\DependencyInjection\Reference; 18 | 19 | final class AsFixtureStoryCompilerPass implements CompilerPassInterface 20 | { 21 | public function process(ContainerBuilder $container): void 22 | { 23 | if (!$container->has('.zenstruck_foundry.command.load_fixtures')) { 24 | return; 25 | } 26 | 27 | /** @var array $fixtureStories */ 28 | $fixtureStories = []; 29 | $groupedFixtureStories = []; 30 | foreach ($container->findTaggedServiceIds('foundry.story.fixture') as $id => $tags) { 31 | if (1 !== \count($tags)) { 32 | throw new LogicException('Tag "foundry.story.fixture" must be used only once per service.'); 33 | } 34 | 35 | $name = $tags[0]['name']; 36 | 37 | if (isset($fixtureStories[$name])) { 38 | throw new LogicException("Cannot use #[AsFixture] name \"{$name}\" for service \"{$id}\". This name is already used by service \"{$fixtureStories[$name]}\"."); 39 | } 40 | 41 | $storyClass = $container->findDefinition($id)->getClass(); 42 | 43 | $fixtureStories[$name] = $storyClass; 44 | 45 | $groups = $tags[0]['groups']; 46 | if (!$groups) { 47 | continue; 48 | } 49 | 50 | foreach ($groups as $group) { 51 | $groupedFixtureStories[$group] ??= []; 52 | $groupedFixtureStories[$group][$name] = $storyClass; 53 | } 54 | } 55 | 56 | if ($collisionNames = \array_intersect(\array_keys($fixtureStories), \array_keys($groupedFixtureStories))) { 57 | $collisionNames = \implode('", "', $collisionNames); 58 | throw new LogicException("Cannot use #[AsFixture] group(s) \"{$collisionNames}\", they collide with fixture names."); 59 | } 60 | 61 | $container->findDefinition('.zenstruck_foundry.command.load_fixtures') 62 | ->setArgument('$stories', $fixtureStories) 63 | ->setArgument('$groupedStories', $groupedFixtureStories); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/functions.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry; 13 | 14 | use Faker; 15 | use Zenstruck\Foundry\Object\Hydrator; 16 | 17 | function faker(): Faker\Generator 18 | { 19 | return Configuration::instance()->faker; 20 | } 21 | 22 | /** 23 | * Create an anonymous factory for the given class. 24 | * 25 | * @template T of object 26 | * 27 | * @param class-string $class 28 | * @param array|callable(int):array $attributes 29 | * 30 | * @return ObjectFactory 31 | */ 32 | function factory(string $class, array|callable $attributes = []): ObjectFactory 33 | { 34 | return AnonymousFactoryGenerator::create($class, ObjectFactory::class)::new($attributes); 35 | } 36 | 37 | /** 38 | * Instantiate the given class. 39 | * 40 | * @template T of object 41 | * 42 | * @param class-string $class 43 | * @param array|callable(int):array $attributes 44 | * 45 | * @return T 46 | */ 47 | function object(string $class, array|callable $attributes = []): object 48 | { 49 | return factory($class, $attributes)->create(); 50 | } 51 | 52 | /** 53 | * "Force set" (using reflection) an object property. 54 | * 55 | * @template T of object 56 | * @param T $object 57 | * 58 | * @return T 59 | */ 60 | function set(object $object, string $property, mixed $value): object 61 | { 62 | Hydrator::set($object, $property, $value); 63 | 64 | return $object; 65 | } 66 | 67 | /** 68 | * "Force get" (using reflection) an object property. 69 | */ 70 | function get(object $object, string $property): mixed 71 | { 72 | return Hydrator::get($object, $property); 73 | } 74 | 75 | /** 76 | * Create a "lazy" factory attribute which will only be evaluated 77 | * if used. 78 | * 79 | * @param callable():mixed $factory 80 | */ 81 | function lazy(callable $factory): LazyValue 82 | { 83 | return LazyValue::new($factory); 84 | } 85 | 86 | /** 87 | * Same as {@see lazy()} but subsequent evaluations will return the 88 | * same value. 89 | * 90 | * @param callable():mixed $factory 91 | */ 92 | function memoize(callable $factory): LazyValue 93 | { 94 | return LazyValue::memoize($factory); 95 | } 96 | 97 | /** 98 | * Allows to force a single property. 99 | */ 100 | function force(mixed $value): ForceValue 101 | { 102 | return new ForceValue($value); 103 | } 104 | -------------------------------------------------------------------------------- /docs/.doctor-rst.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | american_english: ~ 3 | avoid_repetetive_words: ~ 4 | blank_line_after_anchor: ~ 5 | blank_line_after_directive: ~ 6 | blank_line_before_directive: ~ 7 | composer_dev_option_not_at_the_end: ~ 8 | correct_code_block_directive_based_on_the_content: ~ 9 | deprecated_directive_should_have_version: ~ 10 | ensure_bash_prompt_before_composer_command: ~ 11 | ensure_correct_format_for_phpfunction: ~ 12 | ensure_exactly_one_space_before_directive_type: ~ 13 | ensure_exactly_one_space_between_link_definition_and_link: ~ 14 | ensure_explicit_nullable_types: ~ 15 | ensure_github_directive_start_with_prefix: 16 | prefix: 'Foundry' 17 | ensure_link_bottom: ~ 18 | ensure_link_definition_contains_valid_url: ~ 19 | ensure_order_of_code_blocks_in_configuration_block: ~ 20 | ensure_php_reference_syntax: ~ 21 | extend_abstract_controller: ~ 22 | # extension_xlf_instead_of_xliff: ~ 23 | forbidden_directives: 24 | directives: 25 | - '.. index::' 26 | indention: ~ 27 | lowercase_as_in_use_statements: ~ 28 | max_blank_lines: 29 | max: 2 30 | max_colons: ~ 31 | no_app_console: ~ 32 | no_attribute_redundant_parenthesis: ~ 33 | no_blank_line_after_filepath_in_php_code_block: ~ 34 | no_blank_line_after_filepath_in_twig_code_block: ~ 35 | no_blank_line_after_filepath_in_xml_code_block: ~ 36 | no_blank_line_after_filepath_in_yaml_code_block: ~ 37 | no_brackets_in_method_directive: ~ 38 | no_composer_req: ~ 39 | no_directive_after_shorthand: ~ 40 | no_duplicate_use_statements: ~ 41 | no_explicit_use_of_code_block_php: ~ 42 | no_footnotes: ~ 43 | no_inheritdoc: ~ 44 | no_merge_conflict: ~ 45 | no_namespace_after_use_statements: ~ 46 | no_php_open_tag_in_code_block_php_directive: ~ 47 | no_space_before_self_xml_closing_tag: ~ 48 | only_backslashes_in_namespace_in_php_code_block: ~ 49 | only_backslashes_in_use_statements_in_php_code_block: ~ 50 | ordered_use_statements: ~ 51 | php_prefix_before_bin_console: ~ 52 | remove_trailing_whitespace: ~ 53 | replace_code_block_types: ~ 54 | replacement: ~ 55 | short_array_syntax: ~ 56 | space_between_label_and_link_in_doc: ~ 57 | space_between_label_and_link_in_ref: ~ 58 | string_replacement: ~ 59 | title_underline_length_must_match_title_length: ~ 60 | typo: ~ 61 | unused_links: ~ 62 | use_deprecated_directive_instead_of_versionadded: ~ 63 | use_named_constructor_without_new_keyword_rule: ~ 64 | use_https_xsd_urls: ~ 65 | valid_inline_highlighted_namespaces: ~ 66 | valid_use_statements: ~ 67 | versionadded_directive_should_have_version: ~ 68 | yaml_instead_of_yml_suffix: ~ 69 | -------------------------------------------------------------------------------- /config/persistence.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\DependencyInjection\Loader\Configurator; 13 | 14 | use Symfony\Component\Console\Event\ConsoleTerminateEvent; 15 | use Symfony\Component\HttpKernel\Event\TerminateEvent; 16 | use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent; 17 | use Zenstruck\Foundry\Command\LoadFixturesCommand; 18 | use Zenstruck\Foundry\Persistence\Event\AfterPersist; 19 | use Zenstruck\Foundry\Persistence\PersistedObjectsTracker; 20 | use Zenstruck\Foundry\Persistence\PersistenceManager; 21 | use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager; 22 | 23 | return static function(ContainerConfigurator $container): void { 24 | $container->services() 25 | ->set('.zenstruck_foundry.persistence_manager', PersistenceManager::class) 26 | ->args([ 27 | tagged_iterator('.foundry.persistence_strategy'), 28 | service('.zenstruck_foundry.persistence.reset_database_manager'), 29 | ]) 30 | ->set('.zenstruck_foundry.persistence.reset_database_manager', ResetDatabaseManager::class) 31 | ->args([ 32 | tagged_iterator('.foundry.persistence.database_resetter'), 33 | tagged_iterator('.foundry.persistence.schema_resetter'), 34 | ]) 35 | 36 | ->set('.zenstruck_foundry.command.load_fixtures', LoadFixturesCommand::class) 37 | ->arg('$databaseResetters', tagged_iterator('.foundry.persistence.database_resetter')) 38 | ->arg('$kernel', service('kernel')) 39 | ->tag('console.command', [ 40 | 'command' => 'foundry:load-fixtures|foundry:load-stories|foundry:load-story', 41 | 'description' => 'Load stories which are marked with #[AsFixture] attribute.', 42 | ]) 43 | ; 44 | 45 | if (\PHP_VERSION_ID >= 80400) { 46 | $container->services()->set('.foundry.persistence.objects_tracker', PersistedObjectsTracker::class) 47 | ->tag('kernel.reset', ['method' => 'refresh']) 48 | ->tag('kernel.event_listener', ['event' => TerminateEvent::class, 'method' => 'refresh']) 49 | ->tag('kernel.event_listener', ['event' => ConsoleTerminateEvent::class, 'method' => 'refresh']) 50 | ->tag('kernel.event_listener', ['event' => WorkerMessageHandledEvent::class, 'method' => 'refresh']) // @phpstan-ignore class.notFound 51 | ->tag('foundry.hook', ['class' => null, 'method' => 'afterPersistHook', 'event' => AfterPersist::class]) 52 | ; 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /skeleton/Factory.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | getUses() as $use) { 7 | echo "use {$use};\n"; 8 | } 9 | ?> 10 | 11 | /** 12 | * @extends getFactoryClassShortName(); ?><getObjectShortName(); ?>> 13 | getMethodsPHPDoc())) { 15 | echo " *\n"; 16 | foreach ($makeFactoryData->getMethodsPHPDoc() as $methodPHPDoc) { 17 | echo "{$methodPHPDoc->toString()}\n"; 18 | } 19 | 20 | echo " *\n"; 21 | 22 | foreach ($makeFactoryData->getMethodsPHPDoc() as $methodPHPDoc) { 23 | echo "{$methodPHPDoc->toString($makeFactoryData->staticAnalysisTool())}\n"; 24 | } 25 | } 26 | ?> 27 | */ 28 | final class extends getFactoryClassShortName(); ?> 29 | { 30 | shouldAddHints()): ?> /** 31 | * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services 32 | * 33 | * @todo inject services if required 34 | */ 35 | public function __construct() 36 | { 37 | } 38 | 39 | shouldAddOverrideAttributes()): ?> #[\Override] 40 | public static function class(): string 41 | { 42 | return getObjectShortName(); ?>::class; 43 | } 44 | 45 | shouldAddHints()): ?> /** 46 | * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories 47 | * 48 | * @todo add your default values here 49 | */ 50 | shouldAddOverrideAttributes()): ?> #[\Override] 51 | protected function defaults(): arrayshouldAddHints()): ?>|callable 52 | { 53 | return [ 54 | getDefaultProperties() as $propertyName => $value) { 56 | echo " '{$propertyName}' => {$value}\n"; 57 | } 58 | ?> 59 | ]; 60 | } 61 | 62 | shouldAddHints()): ?> /** 63 | * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization 64 | */ 65 | shouldAddOverrideAttributes()): ?> #[\Override] 66 | protected function initialize(): static 67 | { 68 | return $this 69 | // ->afterInstantiate(function(getObjectShortName(); ?> $getObjectShortName()); ?>): void {}) 70 | ; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Exception/FactoriesTraitNotUsed.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Exception; 15 | 16 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 17 | use Zenstruck\Foundry\Test\Factories; 18 | 19 | /** 20 | * @author Nicolas PHILIPPE 21 | */ 22 | final class FactoriesTraitNotUsed extends \LogicException 23 | { 24 | /** 25 | * @param class-string $class 26 | */ 27 | private function __construct(string $class) 28 | { 29 | parent::__construct( 30 | \sprintf('You must use the trait "%s" in "%s" in order to use Foundry.', Factories::class, $class) 31 | ); 32 | } 33 | 34 | public static function throwIfComingFromKernelTestCaseWithoutFactoriesTrait(): void 35 | { 36 | $backTrace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS); // @phpstan-ignore ekinoBannedCode.function 37 | 38 | $testClassesExtendingKernelTestCase = \array_column( 39 | \array_filter( 40 | $backTrace, 41 | static fn(array $trace): bool => '->' === ($trace['type'] ?? null) 42 | && isset($trace['class']) 43 | && KernelTestCase::class !== $trace['class'] 44 | && \is_a($trace['class'], KernelTestCase::class, allow_string: true) 45 | ), 46 | 'class' 47 | ); 48 | 49 | if (!$testClassesExtendingKernelTestCase) { 50 | // no KernelTestCase found in backtrace, so nothing to check 51 | return; 52 | } 53 | 54 | self::throwIfClassDoesNotHaveFactoriesTrait(...$testClassesExtendingKernelTestCase); 55 | } 56 | 57 | /** 58 | * @param class-string $classes 59 | */ 60 | public static function throwIfClassDoesNotHaveFactoriesTrait(string ...$classes): void 61 | { 62 | if (array_any($classes, static fn(string $class): bool => (new \ReflectionClass($class))->hasMethod('_beforeHook'))) { 63 | // at least one KernelTestCase class in the backtrace uses Factories trait, nothing to do 64 | return; 65 | } 66 | 67 | // throw new self($class); 68 | 69 | trigger_deprecation( 70 | 'zenstruck/foundry', 71 | '2.4', 72 | 'In order to use Foundry correctly, you must use the trait "%s" in your "%s" tests. This will throw an exception in 3.0.', 73 | Factories::class, 74 | $classes[\array_key_last($classes)] 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Maker/NamespaceGuesser.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Maker; 15 | 16 | use Symfony\Bundle\MakerBundle\Generator; 17 | use Symfony\Bundle\MakerBundle\Str; 18 | use Zenstruck\Foundry\Persistence\PersistenceManager; 19 | 20 | /** 21 | * Guesses namespaces depending on: 22 | * - user input "--namespace": will be used as a prefix (after root namespace "App\") 23 | * - user input "--test": will add "Test\" just after root namespace 24 | * - doctrine mapping: if the original class is a doctrine object, will suffix the namespace relative to doctrine's mapping. 25 | * 26 | * @internal 27 | */ 28 | final class NamespaceGuesser 29 | { 30 | /** @var list */ 31 | private array $doctrineNamespaces; 32 | 33 | public function __construct(?PersistenceManager $persistenceManager) 34 | { 35 | $this->doctrineNamespaces = $persistenceManager?->managedNamespaces() ?? []; 36 | } 37 | 38 | public function __invoke(Generator $generator, string $originalClass, string $baseNamespace, bool $test): string 39 | { 40 | // strip maker's root namespace if set 41 | $baseNamespace = $this->stripRootNamespace($baseNamespace, $generator->getRootNamespace()); 42 | 43 | $doctrineBasedNamespace = $this->namespaceSuffixFromDoctrineMapping($originalClass); 44 | 45 | if ($doctrineBasedNamespace) { 46 | $baseNamespace = "{$baseNamespace}\\{$doctrineBasedNamespace}"; 47 | } 48 | 49 | // if creating in tests dir, ensure namespace prefixed with Tests\ 50 | if ($test && 0 !== \mb_strpos($baseNamespace, 'Tests\\')) { 51 | $baseNamespace = 'Tests\\'.$baseNamespace; 52 | } 53 | 54 | return $baseNamespace; 55 | } 56 | 57 | private function namespaceSuffixFromDoctrineMapping(string $originalClass): ?string 58 | { 59 | $originalClassNamespace = Str::getNamespace($originalClass); 60 | 61 | foreach ($this->doctrineNamespaces as $doctrineNamespace) { 62 | if (\str_starts_with($originalClassNamespace, $doctrineNamespace)) { 63 | return $this->stripRootNamespace($originalClassNamespace, $doctrineNamespace); 64 | } 65 | } 66 | 67 | return null; 68 | } 69 | 70 | private static function stripRootNamespace(string $class, string $rootNamespace): string 71 | { 72 | if (0 === \mb_strpos($class, $rootNamespace)) { 73 | $class = \mb_substr($class, \mb_strlen($rootNamespace)); 74 | } 75 | 76 | return \trim($class, '\\'); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/StoryRegistry.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry; 13 | 14 | /** 15 | * @author Kevin Bond 16 | * 17 | * @internal 18 | */ 19 | final class StoryRegistry 20 | { 21 | /** @var array */ 22 | private static array $globalInstances = []; 23 | 24 | /** @var array */ 25 | private static array $instances = []; 26 | 27 | /** 28 | * @param Story[] $stories 29 | * @param list|callable():void> $globalStories 30 | */ 31 | public function __construct(private iterable $stories, private array $globalStories = []) 32 | { 33 | } 34 | 35 | /** 36 | * @template T of Story 37 | * 38 | * @param class-string $class 39 | * 40 | * @return T 41 | */ 42 | public function load(string $class): Story 43 | { 44 | if (\array_key_exists($class, self::$globalInstances)) { 45 | return self::$globalInstances[$class]; // @phpstan-ignore return.type 46 | } 47 | 48 | if (\array_key_exists($class, self::$instances)) { 49 | return self::$instances[$class]; // @phpstan-ignore return.type 50 | } 51 | 52 | self::$instances[$class] = $this->getOrCreateStory($class); 53 | self::$instances[$class]->build(); 54 | 55 | return self::$instances[$class]; 56 | } 57 | 58 | public function loadGlobalStories(): void 59 | { 60 | self::$globalInstances = []; 61 | 62 | foreach ($this->globalStories as $story) { 63 | \is_a($story, Story::class, true) ? $this->load($story) : $story(); // @phpstan-ignore argument.type, argument.type 64 | } 65 | 66 | self::$globalInstances = self::$instances; 67 | self::$instances = []; 68 | } 69 | 70 | public static function reset(): void 71 | { 72 | self::$instances = []; 73 | } 74 | 75 | /** 76 | * @template T of Story 77 | * 78 | * @param class-string $class 79 | * 80 | * @return T 81 | */ 82 | private function getOrCreateStory(string $class): Story 83 | { 84 | foreach ($this->stories as $story) { 85 | if ($class === $story::class) { 86 | return $story; // @phpstan-ignore return.type 87 | } 88 | } 89 | 90 | try { 91 | return new $class(); 92 | } catch (\ArgumentCountError $e) { // @phpstan-ignore catch.neverThrown 93 | throw new \RuntimeException('Stories with dependencies (Story services) cannot be used without the foundry bundle.', 0, $e); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /utils/rector/src/RemoveFunctionCall/RemoveFunctionCallRector.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Utils\Rector\RemoveFunctionCall; 15 | 16 | use PhpParser\Node; 17 | use PhpParser\NodeVisitor; 18 | use Rector\Contract\Rector\ConfigurableRectorInterface; 19 | use Rector\Rector\AbstractRector; 20 | 21 | final class RemoveFunctionCallRector extends AbstractRector implements ConfigurableRectorInterface 22 | { 23 | /** @var RemoveFunctionCall[] */ 24 | private array $removeFunctionCalls = []; 25 | 26 | /** @return array> */ 27 | public function getNodeTypes(): array 28 | { 29 | return [Node\Stmt\Expression::class]; 30 | } 31 | 32 | /** @param Node\Stmt\Expression $node */ 33 | public function refactor(Node $node): ?int 34 | { 35 | foreach ($this->removeFunctionCalls as $removeFunctionCall) { 36 | if ($node->expr instanceof Node\Expr\FuncCall && !$node->expr->isFirstClassCallable() && $this->isName($node->expr, $removeFunctionCall->functionName)) { 37 | return NodeVisitor::REMOVE_NODE; 38 | } 39 | 40 | $assign = $node->expr; 41 | 42 | if (!$assign instanceof Node\Expr\Assign) { 43 | return null; 44 | } 45 | 46 | if ($assign->expr instanceof Node\Expr\FuncCall && !$assign->expr->isFirstClassCallable() && $this->isName($assign->expr, $removeFunctionCall->functionName)) { 47 | if (!isset($assign->expr->args[0]) || $assign->expr->args[0] instanceof Node\VariadicPlaceholder) { 48 | return null; 49 | } 50 | 51 | if ($assign->var instanceof Node\Expr\Variable 52 | && $assign->expr->args[0]->value instanceof Node\Expr\Variable 53 | && $assign->var->name === $assign->expr->args[0]->value->name) { 54 | return NodeVisitor::REMOVE_NODE; 55 | } 56 | 57 | $assign->expr = $assign->expr->args[0]->value; 58 | } 59 | } 60 | 61 | return null; 62 | } 63 | 64 | /** 65 | * @param mixed[] $configuration 66 | */ 67 | public function configure(array $configuration): void 68 | { 69 | foreach ($configuration as $configItem) { 70 | if (!$configItem instanceof RemoveFunctionCall) { 71 | throw new \InvalidArgumentException(\sprintf('Expected instance of "%s", got "%s".', RemoveFunctionCall::class, \get_debug_type($configItem))); 72 | } 73 | } 74 | $this->removeFunctionCalls = $configuration; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Maker/Factory/MakeFactoryQuery.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker\Factory; 13 | 14 | use Symfony\Bundle\MakerBundle\Generator; 15 | use Symfony\Component\Console\Input\InputInterface; 16 | 17 | /** 18 | * @internal 19 | */ 20 | final class MakeFactoryQuery 21 | { 22 | private function __construct( 23 | private string $namespace, 24 | private bool $test, 25 | private bool $persisted, 26 | private bool $allFields, 27 | private bool $withPhpDoc, 28 | private string $class, 29 | private bool $generateAllFactories, 30 | private bool $addHints, 31 | private Generator $generator, 32 | ) { 33 | } 34 | 35 | public static function fromInput(InputInterface $input, string $class, bool $generateAllFactories, Generator $generator, string $defaultNamespace, bool $addHints): self 36 | { 37 | return new self( 38 | namespace: $defaultNamespace, 39 | test: (bool) $input->getOption('test'), 40 | persisted: !$input->getOption('no-persistence'), 41 | allFields: (bool) $input->getOption('all-fields'), 42 | withPhpDoc: (bool) $input->getOption('with-phpdoc'), 43 | class: $class, 44 | generateAllFactories: $generateAllFactories, 45 | addHints: $addHints, 46 | generator: $generator, 47 | ); 48 | } 49 | 50 | public function getNamespace(): string 51 | { 52 | return $this->namespace; 53 | } 54 | 55 | public function isTest(): bool 56 | { 57 | return $this->test; 58 | } 59 | 60 | public function isPersisted(): bool 61 | { 62 | return $this->persisted; 63 | } 64 | 65 | public function isAllFields(): bool 66 | { 67 | return $this->allFields; 68 | } 69 | 70 | public function addPhpDoc(): bool 71 | { 72 | return $this->withPhpDoc; 73 | } 74 | 75 | public function getClass(): string 76 | { 77 | return $this->class; 78 | } 79 | 80 | public function generateAllFactories(): bool 81 | { 82 | return $this->generateAllFactories; 83 | } 84 | 85 | public function getGenerator(): Generator 86 | { 87 | return $this->generator; 88 | } 89 | 90 | public function withClass(string $class): self 91 | { 92 | $clone = clone $this; 93 | $clone->class = $class; 94 | 95 | return $clone; 96 | } 97 | 98 | public function shouldAddHints(): bool 99 | { 100 | return $this->addHints; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /phpunit-deprecation-baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /utils/rector/src/RemoveUnproxifyArrayMapRector.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Utils\Rector; 15 | 16 | use PhpParser\Node; 17 | use PhpParser\Node\FunctionLike; 18 | use PHPStan\Type\ClosureType; 19 | use PHPStan\Type\MixedType; 20 | use PHPStan\Type\ObjectType; 21 | use Rector\Rector\AbstractRector; 22 | use Zenstruck\Foundry\Persistence\Proxy; 23 | 24 | /** 25 | * Remove useless array_map() which calls ->_real() on proxies list. 26 | */ 27 | final class RemoveUnproxifyArrayMapRector extends AbstractRector 28 | { 29 | /** 30 | * @return array> 31 | */ 32 | public function getNodeTypes(): array 33 | { 34 | return [Node\Expr\FuncCall::class]; 35 | } 36 | 37 | /** 38 | * @param Node\Expr\FuncCall $node 39 | */ 40 | public function refactor(Node $node): ?Node 41 | { 42 | if ('array_map' !== $this->getName($node->name)) { 43 | return null; 44 | } 45 | 46 | if (2 !== \count($node->args)) { 47 | return null; 48 | } 49 | 50 | // if the callable looks like "fn(Proxy $p) => $p->object()" 51 | if (!$this->isCallableUnproxify($node)) { 52 | return null; 53 | } 54 | 55 | // then replace the array_map by it's array param 56 | return $node->getArgs()[1]->value; 57 | } 58 | 59 | private function isCallableUnproxify(Node\Expr\FuncCall $node): bool 60 | { 61 | $callable = $node->getArgs()[0]->value; 62 | 63 | if (!$this->getType($callable) instanceof ClosureType) { 64 | return false; // first argument can be any type of callable, but let's only handle closures 65 | } 66 | 67 | if (!$callable instanceof FunctionLike) { 68 | return false; // at this point this shoudl not happend 69 | } 70 | 71 | if (1 !== \count($callable->getParams())) { 72 | return false; // let's only handle callables with one param 73 | } 74 | 75 | $paramType = $this->getType($callable->getParams()[0]); 76 | 77 | if (!$paramType->accepts(new ObjectType(Proxy::class), true)->yes() 78 | || $paramType instanceof MixedType 79 | ) { 80 | return false; 81 | } 82 | 83 | // assert the body of the callable is a single ->_real() call on its unique param 84 | return 1 === \count($callable->getStmts() ?? []) 85 | && ($return = $callable->getStmts()[0]) instanceof Node\Stmt\Return_ 86 | && (($methodCall = $return->expr) instanceof Node\Expr\MethodCall || $methodCall instanceof Node\Expr\NullsafeMethodCall) 87 | && $this->getName($methodCall->var) === $this->getName($callable->getParams()[0]->var) 88 | && '_real' === $this->getName($methodCall->name) 89 | && 0 === \count($methodCall->args) 90 | ; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Maker/Factory/ORMDefaultPropertiesGuesser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker\Factory; 13 | 14 | use Doctrine\ORM\Mapping\ClassMetadata as ORMClassMetadata; 15 | use Doctrine\ORM\Mapping\ToOneAssociationMapping; 16 | use Symfony\Component\Console\Style\SymfonyStyle; 17 | use Zenstruck\Foundry\ORM\DoctrineOrmVersionGuesser; 18 | use Zenstruck\Foundry\Persistence\Exception\NoPersistenceStrategy; 19 | 20 | /** 21 | * @internal 22 | */ 23 | final class ORMDefaultPropertiesGuesser extends AbstractDoctrineDefaultPropertiesGuesser 24 | { 25 | public function __invoke(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery): void 26 | { 27 | if (!DoctrineOrmVersionGuesser::isOrmV3()) { 28 | return; 29 | } 30 | 31 | $metadata = $this->getClassMetadata($makeFactoryData); 32 | 33 | if (!$metadata instanceof ORMClassMetadata) { 34 | throw new \InvalidArgumentException("\"{$makeFactoryData->getObjectFullyQualifiedClassName()}\" is not a valid ORM class."); 35 | } 36 | 37 | $this->guessDefaultValueForORMAssociativeFields($io, $makeFactoryData, $makeFactoryQuery, $metadata); 38 | $this->guessDefaultValueForEmbedded($io, $makeFactoryData, $makeFactoryQuery, $metadata); 39 | } 40 | 41 | public function supports(MakeFactoryData $makeFactoryData): bool 42 | { 43 | try { 44 | $metadata = $this->getClassMetadata($makeFactoryData); 45 | 46 | return $metadata instanceof ORMClassMetadata; 47 | } catch (NoPersistenceStrategy) { 48 | return false; 49 | } 50 | } 51 | 52 | private function guessDefaultValueForORMAssociativeFields(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery, ORMClassMetadata $metadata): void 53 | { 54 | foreach ($metadata->associationMappings as $item) { 55 | if (!$item instanceof ToOneAssociationMapping) { 56 | // we don't want to add defaults for X-To-Many relationships 57 | continue; 58 | } 59 | 60 | if ($item->joinColumns[0]->nullable ?? true) { 61 | continue; 62 | } 63 | 64 | $this->addDefaultValueUsingFactory($io, $makeFactoryData, $makeFactoryQuery, $item->fieldName, $item->targetEntity); 65 | } 66 | } 67 | 68 | private function guessDefaultValueForEmbedded(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery, ORMClassMetadata $metadata): void 69 | { 70 | foreach ($metadata->embeddedClasses as $fieldName => $item) { 71 | $isNullable = $makeFactoryData->getObject()->getProperty($fieldName)->getType()?->allowsNull() ?? true; 72 | 73 | if (!$makeFactoryQuery->isAllFields() && $isNullable) { 74 | continue; 75 | } 76 | 77 | $this->addDefaultValueUsingFactory($io, $makeFactoryData, $makeFactoryQuery, $fieldName, $item->class); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Persistence/PersistenceStrategy.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Persistence; 13 | 14 | use Doctrine\Persistence\ManagerRegistry; 15 | use Doctrine\Persistence\Mapping\ClassMetadata; 16 | use Doctrine\Persistence\Mapping\MappingException; 17 | use Doctrine\Persistence\ObjectManager; 18 | use Zenstruck\Foundry\Persistence\Relationship\RelationshipMetadata; 19 | 20 | /** 21 | * @author Kevin Bond 22 | * 23 | * @internal 24 | */ 25 | abstract class PersistenceStrategy 26 | { 27 | public function __construct(protected readonly ManagerRegistry $registry) 28 | { 29 | } 30 | 31 | /** 32 | * @param class-string $class 33 | */ 34 | public function supports(string $class): bool 35 | { 36 | return (bool) $this->registry->getManagerForClass($class); 37 | } 38 | 39 | /** 40 | * @param class-string $class 41 | */ 42 | public function objectManagerFor(string $class): ObjectManager 43 | { 44 | return $this->registry->getManagerForClass($class) ?? throw new \LogicException(\sprintf('No manager found for "%s".', $class)); 45 | } 46 | 47 | /** 48 | * @return ObjectManager[] 49 | */ 50 | public function objectManagers(): array 51 | { 52 | return $this->registry->getManagers(); 53 | } 54 | 55 | /** 56 | * @param class-string $parent 57 | * @param class-string $child 58 | */ 59 | public function bidirectionalRelationshipMetadata(string $parent, string $child, string $field): ?RelationshipMetadata 60 | { 61 | return null; 62 | } 63 | 64 | /** 65 | * @template T of object 66 | * @param class-string $class 67 | * @return ClassMetadata 68 | * 69 | * @throws MappingException If $class is not managed by Doctrine 70 | */ 71 | public function classMetadata(string $class): ClassMetadata 72 | { 73 | return $this->objectManagerFor($class)->getClassMetadata($class); 74 | } 75 | 76 | abstract public function hasChanges(object $object): bool; 77 | 78 | abstract public function contains(object $object): bool; 79 | 80 | abstract public function truncate(string $class): void; 81 | 82 | /** 83 | * @return array 84 | */ 85 | public function getIdentifierValues(object $object): array 86 | { 87 | return $this->classMetadata($object::class)->getIdentifierValues($object); 88 | } 89 | 90 | /** 91 | * @return list 92 | */ 93 | abstract public function managedNamespaces(): array; 94 | 95 | /** 96 | * @param class-string $owner 97 | * 98 | * @return array|null 99 | */ 100 | abstract public function embeddablePropertiesFor(object $object, string $owner): ?array; 101 | 102 | abstract public function isEmbeddable(object $object): bool; 103 | 104 | abstract public function isScheduledForInsert(object $object): bool; 105 | } 106 | -------------------------------------------------------------------------------- /src/ORM/ResetDatabase/BaseOrmResetter.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\ORM\ResetDatabase; 15 | 16 | use Doctrine\Bundle\DoctrineBundle\Registry; 17 | use Doctrine\DBAL\Connection; 18 | use Doctrine\DBAL\Platforms\PostgreSQLPlatform; 19 | use Doctrine\DBAL\Platforms\SQLitePlatform; 20 | use Symfony\Bundle\FrameworkBundle\Console\Application; 21 | use Symfony\Component\Filesystem\Filesystem; 22 | use Symfony\Component\HttpKernel\KernelInterface; 23 | 24 | use function Zenstruck\Foundry\runCommand; 25 | 26 | /** 27 | * @author Nicolas PHILIPPE 28 | * @internal 29 | */ 30 | abstract class BaseOrmResetter implements OrmResetter 31 | { 32 | private static bool $inFirstTest = true; 33 | 34 | /** 35 | * @param list $managers 36 | * @param list $connections 37 | */ 38 | public function __construct( 39 | private readonly Registry $registry, 40 | protected readonly array $managers, 41 | protected readonly array $connections, 42 | ) { 43 | } 44 | 45 | final public function resetBeforeEachTest(KernelInterface $kernel): void 46 | { 47 | if (self::$inFirstTest) { 48 | self::$inFirstTest = false; 49 | 50 | return; 51 | } 52 | 53 | $this->doResetBeforeEachTest($kernel); 54 | } 55 | 56 | abstract protected function doResetBeforeEachTest(KernelInterface $kernel): void; 57 | 58 | final protected function dropAndResetDatabase(Application $application): void 59 | { 60 | foreach ($this->connections as $connectionName) { 61 | /** @var Connection $connection */ 62 | $connection = $this->registry->getConnection($connectionName); 63 | $databasePlatform = $connection->getDatabasePlatform(); 64 | 65 | if ($databasePlatform instanceof SQLitePlatform) { 66 | // we don't need to create the sqlite database - it's created when the schema is created 67 | // let's only drop the .db file 68 | 69 | $dbPath = $connection->getParams()['path'] ?? null; // @phpstan-ignore method.internal 70 | if ($dbPath && (new Filesystem())->exists($dbPath)) { 71 | \file_put_contents($dbPath, ''); 72 | } 73 | 74 | continue; 75 | } 76 | 77 | if ($databasePlatform instanceof PostgreSQLPlatform) { 78 | // let's drop all connections to the database to be able to drop it 79 | $sql = 'SELECT pid, pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = current_database() AND pid <> pg_backend_pid()'; 80 | runCommand($application, "dbal:run-sql --connection={$connectionName} '{$sql}'", canFail: true); 81 | } 82 | 83 | runCommand($application, "doctrine:database:drop --connection={$connectionName} --force --if-exists"); 84 | 85 | runCommand($application, "doctrine:database:create --connection={$connectionName}"); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Mongo/MongoPersistenceStrategy.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Mongo; 13 | 14 | use Doctrine\ODM\MongoDB\DocumentManager; 15 | use Doctrine\ODM\MongoDB\Mapping\MappingException as MongoMappingException; 16 | use Doctrine\Persistence\Mapping\MappingException; 17 | use Zenstruck\Foundry\Persistence\PersistenceStrategy; 18 | 19 | /** 20 | * @author Kevin Bond 21 | * 22 | * @internal 23 | * 24 | * @method DocumentManager objectManagerFor(string $class) 25 | * @method list objectManagers() 26 | */ 27 | final class MongoPersistenceStrategy extends PersistenceStrategy 28 | { 29 | public function contains(object $object): bool 30 | { 31 | $dm = $this->objectManagerFor($object::class); 32 | 33 | return $dm->contains($object) && !$dm->getUnitOfWork()->isScheduledForInsert($object); 34 | } 35 | 36 | public function hasChanges(object $object): bool 37 | { 38 | $dm = $this->objectManagerFor($object::class); 39 | 40 | if (!$dm->contains($object)) { 41 | return false; 42 | } 43 | 44 | // cannot use UOW::recomputeSingleEntityChangeSet() here as it wrongly computes embedded objects as changed 45 | $dm->getUnitOfWork()->computeChangeSet($dm->getClassMetadata($object::class), $object); 46 | 47 | return (bool) $dm->getUnitOfWork()->getDocumentChangeSet($object); 48 | } 49 | 50 | public function truncate(string $class): void 51 | { 52 | $this->objectManagerFor($class)->getDocumentCollection($class)->deleteMany([]); 53 | } 54 | 55 | public function managedNamespaces(): array 56 | { 57 | $namespaces = []; 58 | 59 | foreach ($this->objectManagers() as $objectManager) { 60 | $namespaces[] = $objectManager->getConfiguration()->getDocumentNamespaces(); 61 | } 62 | 63 | return \array_values(\array_merge(...$namespaces)); 64 | } 65 | 66 | public function embeddablePropertiesFor(object $object, string $owner): ?array 67 | { 68 | try { 69 | $metadata = $this->objectManagerFor($owner)->getClassMetadata($object::class); 70 | } catch (MappingException|MongoMappingException) { 71 | return null; 72 | } 73 | 74 | if (!$metadata->isEmbeddedDocument) { 75 | return null; 76 | } 77 | 78 | $properties = []; 79 | 80 | foreach ($metadata->getFieldNames() as $field) { 81 | $properties[$field] = $metadata->getFieldValue($object, $field); 82 | } 83 | 84 | return $properties; 85 | } 86 | 87 | public function isEmbeddable(object $object): bool 88 | { 89 | return $this->objectManagerFor($object::class)->getClassMetadata($object::class)->isEmbeddedDocument; 90 | } 91 | 92 | public function isScheduledForInsert(object $object): bool 93 | { 94 | $uow = $this->objectManagerFor($object::class)->getUnitOfWork(); 95 | 96 | return $uow->isScheduledForInsert($object) || $uow->isScheduledForUpsert($object); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/InMemory/InMemoryDoctrineObjectRepositoryAdapter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\InMemory; 13 | 14 | use Doctrine\Persistence\ObjectRepository; 15 | use Zenstruck\Foundry\Configuration; 16 | 17 | use function Zenstruck\Foundry\get; 18 | 19 | /** 20 | * @template T of object 21 | * @implements ObjectRepository 22 | */ 23 | final class InMemoryDoctrineObjectRepositoryAdapter implements ObjectRepository 24 | { 25 | /** @var InMemoryRepository */ 26 | private InMemoryRepository $innerInMemoryRepo; 27 | 28 | /** 29 | * @internal 30 | * 31 | * @param class-string $class 32 | */ 33 | public function __construct(private string $class) 34 | { 35 | if (!Configuration::instance()->isInMemoryEnabled()) { 36 | throw new \LogicException('In-memory repositories are not enabled.'); 37 | } 38 | 39 | $this->innerInMemoryRepo = Configuration::instance()->inMemoryRepositoryRegistry->get($this->class); 40 | } 41 | 42 | public function find(mixed $id): ?object 43 | { 44 | throw new \BadMethodCallException('find() is not supported in in-memory repositories. Use findBy() instead.'); 45 | } 46 | 47 | public function findAll(): array 48 | { 49 | return $this->innerInMemoryRepo->_all(); 50 | } 51 | 52 | public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array 53 | { 54 | $results = \array_filter( 55 | $this->innerInMemoryRepo->_all(), 56 | static function($o) use ($criteria) { 57 | foreach ($criteria as $key => $criterion) { 58 | if (get($o, $key) !== $criterion) { 59 | return false; 60 | } 61 | } 62 | 63 | return true; 64 | } 65 | ); 66 | 67 | $results = \array_values($results); 68 | 69 | if ($orderBy) { 70 | if (\count($orderBy) > 1) { 71 | throw new \InvalidArgumentException('Order by multiple fields is not supported.'); 72 | } 73 | 74 | $field = \array_key_first($orderBy); 75 | $direction = $orderBy[$field]; 76 | 77 | if ('asc' === \mb_strtolower($direction)) { 78 | \usort($results, static fn($a, $b) => get($a, $field) <=> get($b, $field)); 79 | } else { 80 | \usort($results, static fn($a, $b) => get($b, $field) <=> get($a, $field)); 81 | } 82 | } 83 | 84 | if (null !== $offset) { 85 | $results = \array_slice($results, $offset); 86 | } 87 | 88 | if (null !== $limit) { 89 | $results = \array_slice($results, 0, $limit); 90 | } 91 | 92 | return $results; 93 | } 94 | 95 | public function findOneBy(array $criteria): ?object 96 | { 97 | return $this->findBy($criteria, limit: 1)[0] ?? null; 98 | } 99 | 100 | public function getClassName(): string 101 | { 102 | return $this->class; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /utils/psalm/FixProxyFactoryMethodsReturnType.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Psalm; 15 | 16 | use Doctrine\Persistence\ObjectRepository; 17 | use Psalm\Plugin\EventHandler\AfterMethodCallAnalysisInterface; 18 | use Psalm\Plugin\EventHandler\Event\AfterMethodCallAnalysisEvent; 19 | use Psalm\Type; 20 | use Zenstruck\Foundry\FactoryCollection; 21 | use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; 22 | use Zenstruck\Foundry\Persistence\ProxyRepositoryDecorator; 23 | 24 | final class FixProxyFactoryMethodsReturnType implements AfterMethodCallAnalysisInterface 25 | { 26 | public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $event): void 27 | { 28 | [$class, $method] = \explode('::', $event->getMethodId()); 29 | 30 | if ($event->getCodebase()->classExtends($class, PersistentProxyObjectFactory::class)) { 31 | $templateType = $event->getCodebase()->classlikes->getStorageFor( 32 | $class 33 | )->template_extended_params[PersistentProxyObjectFactory::class]['T'] ?? null; 34 | 35 | if (!$templateType) { 36 | return; 37 | } 38 | 39 | $templateTypeAsString = $templateType->getId(); 40 | $proxyTypeHint = "{$templateTypeAsString}&Zenstruck\\Foundry\\Persistence\\Proxy<{$templateTypeAsString}>"; 41 | 42 | $methodsReturningObject = ['create', 'createone', 'find', 'findorcreate', 'first', 'last', 'random', 'randomorcreate']; 43 | if (\in_array($method, $methodsReturningObject, true)) { 44 | $event->setReturnTypeCandidate(Type::parseString($proxyTypeHint)); 45 | } 46 | 47 | $methodsReturningListOfObjects = ['all', 'createmany', 'createrange', 'createsequence', 'findby', 'randomrange', 'randomset']; 48 | if (\in_array($method, $methodsReturningListOfObjects, true)) { 49 | $event->setReturnTypeCandidate(Type::parseString("list<{$proxyTypeHint}>")); 50 | } 51 | 52 | $methodsReturningFactoryCollection = ['many', 'range', 'sequence']; 53 | if (\in_array($method, $methodsReturningFactoryCollection, true)) { 54 | $factoryCollectionClass = FactoryCollection::class; 55 | $event->setReturnTypeCandidate(Type::parseString("{$factoryCollectionClass}<{$proxyTypeHint}>")); 56 | } 57 | 58 | if ('repository' === $method 59 | // if repository() method is overridden in userland, we should not change the return type 60 | && \str_starts_with($event->getReturnTypeCandidate()->getId(), ProxyRepositoryDecorator::class) 61 | ) { 62 | $repositoryDecoratorClass = ProxyRepositoryDecorator::class; 63 | $doctrineRepositoryClass = ObjectRepository::class; 64 | $event->setReturnTypeCandidate( 65 | Type::parseString( 66 | "{$repositoryDecoratorClass}<{$templateTypeAsString}, {$doctrineRepositoryClass}<{$templateTypeAsString}>>" 67 | ) 68 | ); 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Persistence/PersistedObjectsTracker.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Persistence; 13 | 14 | use Zenstruck\Foundry\Configuration; 15 | use Zenstruck\Foundry\ORM\DoctrineOrmVersionGuesser; 16 | use Zenstruck\Foundry\Persistence\Event\AfterPersist; 17 | 18 | /** 19 | * @internal 20 | */ 21 | final class PersistedObjectsTracker 22 | { 23 | /** 24 | * This buffer of objects needs to be static to be kept between two kernel.reset events. 25 | * 26 | * @var \WeakMap keys: objects, values: value ids 27 | */ 28 | private static \WeakMap $trackedObjects; 29 | 30 | public function __construct() 31 | { 32 | self::$trackedObjects ??= new \WeakMap(); 33 | } 34 | 35 | public function refresh(): void 36 | { 37 | self::resetObjectsAsLazyGhosts(); 38 | } 39 | 40 | /** 41 | * @param AfterPersist $event 42 | */ 43 | public function afterPersistHook(AfterPersist $event): void 44 | { 45 | if ($event->factory instanceof PersistentProxyObjectFactory || !$event->factory->isAutorefreshEnabled()) { 46 | return; 47 | } 48 | 49 | $this->add($event->object); 50 | } 51 | 52 | public function add(object ...$objects): void 53 | { 54 | foreach ($objects as $object) { 55 | if (self::$trackedObjects->offsetExists($object) && self::$trackedObjects[$object]) { 56 | if (DoctrineOrmVersionGuesser::isOrmV3()) { 57 | self::resetObjectAsLazyGhost($object, self::$trackedObjects[$object]); 58 | } else { 59 | Configuration::instance()->persistence()->refresh($object, canThrow: false); 60 | } 61 | 62 | continue; 63 | } 64 | 65 | self::$trackedObjects[$object] = Configuration::instance()->persistence()->getIdentifierValues($object); 66 | } 67 | } 68 | 69 | public static function reset(): void 70 | { 71 | self::$trackedObjects = new \WeakMap(); 72 | } 73 | 74 | public static function countObjects(): int 75 | { 76 | return \count(self::$trackedObjects); 77 | } 78 | 79 | private static function resetObjectsAsLazyGhosts(): void 80 | { 81 | if (!Configuration::isBooted()) { 82 | return; 83 | } 84 | 85 | foreach (self::$trackedObjects as $object => $id) { 86 | if (!$id) { 87 | continue; 88 | } 89 | 90 | self::resetObjectAsLazyGhost($object, $id); 91 | } 92 | } 93 | 94 | private static function resetObjectAsLazyGhost(object $object, mixed $id): void 95 | { 96 | $reflector = new \ReflectionClass($object); 97 | 98 | if ($reflector->isUninitializedLazyObject($object)) { 99 | return; 100 | } 101 | 102 | $clone = clone $object; 103 | $reflector->resetAsLazyGhost($object, function($object) use ($clone, $id) { 104 | Configuration::instance()->persistence()->autorefresh($object, $id, $clone); 105 | }); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/ORM/OrmV3PersistenceStrategy.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\ORM; 15 | 16 | use Doctrine\ORM\Mapping\AssociationMapping; 17 | use Doctrine\ORM\Mapping\ManyToOneAssociationMapping; 18 | use Doctrine\ORM\Mapping\MappingException as ORMMappingException; 19 | use Doctrine\ORM\Mapping\OneToManyAssociationMapping; 20 | use Doctrine\ORM\Mapping\OneToOneAssociationMapping; 21 | use Doctrine\Persistence\Mapping\MappingException; 22 | use Zenstruck\Foundry\Persistence\Relationship\ManyToOneRelationship; 23 | use Zenstruck\Foundry\Persistence\Relationship\OneToManyRelationship; 24 | use Zenstruck\Foundry\Persistence\Relationship\OneToOneRelationship; 25 | use Zenstruck\Foundry\Persistence\Relationship\RelationshipMetadata; 26 | 27 | final class OrmV3PersistenceStrategy extends AbstractORMPersistenceStrategy 28 | { 29 | public function bidirectionalRelationshipMetadata(string $parent, string $child, string $field): ?RelationshipMetadata 30 | { 31 | $associationMapping = $this->getAssociationMapping($parent, $child, $field); 32 | 33 | if (null === $associationMapping) { 34 | return null; 35 | } 36 | 37 | if (!\is_a( 38 | $child, 39 | $associationMapping->targetEntity, 40 | allow_string: true 41 | )) { // is_a() handles inheritance as well 42 | throw new \LogicException("Cannot find correct association named \"{$field}\" between classes [parent: \"{$parent}\", child: \"{$child}\"]"); 43 | } 44 | 45 | $inverseField = $associationMapping->isOwningSide() ? $associationMapping->inversedBy : $associationMapping->mappedBy; 46 | 47 | if (null === $inverseField) { 48 | return null; 49 | } 50 | 51 | return match (true) { 52 | $associationMapping instanceof OneToManyAssociationMapping => new OneToManyRelationship( 53 | inverseField: $inverseField, 54 | collectionIndexedBy: $associationMapping->isIndexed() ? $associationMapping->indexBy() : null 55 | ), 56 | $associationMapping instanceof OneToOneAssociationMapping => new OneToOneRelationship( 57 | inverseField: $inverseField, 58 | isOwning: $associationMapping->isOwningSide() 59 | ), 60 | $associationMapping instanceof ManyToOneAssociationMapping => new ManyToOneRelationship( 61 | inverseField: $inverseField, 62 | ), 63 | default => null, 64 | }; 65 | } 66 | 67 | /** 68 | * @param class-string $entityClass 69 | */ 70 | private function getAssociationMapping(string $entityClass, string $targetEntity, string $field): ?AssociationMapping 71 | { 72 | try { 73 | $associationMapping = $this->objectManagerFor($entityClass)->getClassMetadata($entityClass)->getAssociationMapping($field); 74 | } catch (MappingException|ORMMappingException) { 75 | return null; 76 | } 77 | 78 | if (!\is_a($targetEntity, $associationMapping->targetEntity, allow_string: true)) { 79 | return null; 80 | } 81 | 82 | return $associationMapping; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /utils/rector/src/RemoveMethodCall/RemoveMethodCallRector.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Utils\Rector\RemoveMethodCall; 15 | 16 | use PhpParser\Node; 17 | use Rector\Contract\Rector\ConfigurableRectorInterface; 18 | use Rector\Rector\AbstractRector; 19 | 20 | final class RemoveMethodCallRector extends AbstractRector implements ConfigurableRectorInterface 21 | { 22 | /** @var RemoveMethodCall[] */ 23 | private array $removeMethodCalls = []; 24 | 25 | /** @return array> */ 26 | public function getNodeTypes(): array 27 | { 28 | return [Node\Expr\MethodCall::class, Node\Expr\NullsafeMethodCall::class, Node\Stmt\Expression::class]; 29 | } 30 | 31 | /** 32 | * @param Node\Expr\MethodCall|Node\Expr\NullsafeMethodCall|Node\Stmt\Expression $node 33 | * @return Node|null|\PhpParser\NodeVisitor::REMOVE_NODE 34 | */ 35 | public function refactor(Node $node): Node|int|null 36 | { 37 | foreach ($this->removeMethodCalls as $removeMethodCall) { 38 | if ($node instanceof Node\Stmt\Expression) { 39 | // remove calls like "$a->method();" 40 | if ($this->isOnlyMethodCall($node->expr, $removeMethodCall) 41 | ) { 42 | return \PhpParser\NodeVisitor::REMOVE_NODE; 43 | } 44 | 45 | // remove calls like "$a = $a->method();" 46 | if ( 47 | $node->expr instanceof Node\Expr\Assign 48 | && $node->expr->var instanceof Node\Expr\Variable 49 | && $this->isOnlyMethodCall($node->expr->expr, $removeMethodCall) 50 | && $node->expr->expr->var instanceof Node\Expr\Variable 51 | && $node->expr->var->name === $node->expr->expr->var->name 52 | ) { 53 | return \PhpParser\NodeVisitor::REMOVE_NODE; 54 | } 55 | 56 | continue; 57 | } 58 | 59 | if (!$this->isName($node->name, $removeMethodCall->methodName)) { 60 | continue; 61 | } 62 | 63 | return $node->var; 64 | } 65 | 66 | return null; 67 | } 68 | 69 | /** 70 | * @param mixed[] $configuration 71 | */ 72 | public function configure(array $configuration): void 73 | { 74 | foreach ($configuration as $configItem) { 75 | if (!$configItem instanceof RemoveMethodCall) { 76 | throw new \InvalidArgumentException(\sprintf('Expected instance of "%s", got "%s".', RemoveMethodCall::class, \get_debug_type($configItem))); 77 | } 78 | } 79 | 80 | $this->removeMethodCalls = $configuration; 81 | } 82 | 83 | /** 84 | * @phpstan-assert-if-true Node\Expr\MethodCall|Node\Expr\NullsafeMethodCall $expr 85 | */ 86 | private function isOnlyMethodCall(Node\Expr $expr, RemoveMethodCall $removeMethodCall): bool 87 | { 88 | return ($expr instanceof Node\Expr\MethodCall || $expr instanceof Node\Expr\NullsafeMethodCall) 89 | && $this->isName($expr->name, $removeMethodCall->methodName) 90 | && $expr->var instanceof Node\Expr\Variable; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/ORM/AbstractORMPersistenceStrategy.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\ORM; 13 | 14 | use Doctrine\ORM\EntityManagerInterface; 15 | use Doctrine\ORM\Mapping\MappingException as ORMMappingException; 16 | use Doctrine\Persistence\Mapping\MappingException; 17 | use Zenstruck\Foundry\Persistence\PersistenceStrategy; 18 | 19 | /** 20 | * @author Kevin Bond 21 | * 22 | * @internal 23 | * 24 | * @method EntityManagerInterface objectManagerFor(string $class) 25 | * @method list objectManagers() 26 | */ 27 | abstract class AbstractORMPersistenceStrategy extends PersistenceStrategy 28 | { 29 | final public function contains(object $object): bool 30 | { 31 | $em = $this->objectManagerFor($object::class); 32 | 33 | return $em->contains($object) && !$em->getUnitOfWork()->isScheduledForInsert($object); 34 | } 35 | 36 | final public function hasChanges(object $object): bool 37 | { 38 | $em = $this->objectManagerFor($object::class); 39 | 40 | if (!$em->contains($object)) { 41 | return false; 42 | } 43 | 44 | // we're cloning the UOW because computing change set has side effect 45 | $unitOfWork = clone $em->getUnitOfWork(); 46 | 47 | // cannot use UOW::recomputeSingleEntityChangeSet() here as it wrongly computes embedded objects as changed 48 | $unitOfWork->computeChangeSet($em->getClassMetadata($object::class), $object); 49 | 50 | return (bool) $unitOfWork->getEntityChangeSet($object); 51 | } 52 | 53 | final public function truncate(string $class): void 54 | { 55 | $this->objectManagerFor($class)->createQuery("DELETE {$class} e")->execute(); 56 | } 57 | 58 | final public function embeddablePropertiesFor(object $object, string $owner): ?array 59 | { 60 | try { 61 | $metadata = $this->objectManagerFor($owner)->getClassMetadata($object::class); 62 | } catch (MappingException|ORMMappingException) { 63 | return null; 64 | } 65 | 66 | if (!$metadata->isEmbeddedClass) { 67 | return null; 68 | } 69 | 70 | $properties = []; 71 | 72 | foreach ($metadata->getFieldNames() as $field) { 73 | $properties[$field] = $metadata->getFieldValue($object, $field); 74 | } 75 | 76 | return $properties; 77 | } 78 | 79 | final public function isEmbeddable(object $object): bool 80 | { 81 | return $this->objectManagerFor($object::class)->getClassMetadata($object::class)->isEmbeddedClass; 82 | } 83 | 84 | final public function isScheduledForInsert(object $object): bool 85 | { 86 | return $this->objectManagerFor($object::class)->getUnitOfWork()->isScheduledForInsert($object); 87 | } 88 | 89 | final public function managedNamespaces(): array 90 | { 91 | $namespaces = []; 92 | 93 | foreach ($this->objectManagers() as $objectManager) { 94 | $namespaces[] = $objectManager->getConfiguration()->getEntityNamespaces(); 95 | } 96 | 97 | return \array_values(\array_merge(...$namespaces)); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/ORM/OrmV2PersistenceStrategy.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\ORM; 15 | 16 | use Doctrine\ORM\Mapping\ClassMetadataInfo; 17 | use Doctrine\ORM\Mapping\MappingException as ORMMappingException; 18 | use Doctrine\Persistence\Mapping\MappingException; 19 | use Zenstruck\Foundry\Persistence\Relationship\ManyToOneRelationship; 20 | use Zenstruck\Foundry\Persistence\Relationship\OneToManyRelationship; 21 | use Zenstruck\Foundry\Persistence\Relationship\OneToOneRelationship; 22 | use Zenstruck\Foundry\Persistence\Relationship\RelationshipMetadata; 23 | 24 | /** 25 | * @internal 26 | * 27 | * @phpstan-import-type AssociationMapping from \Doctrine\ORM\Mapping\ClassMetadata 28 | */ 29 | final class OrmV2PersistenceStrategy extends AbstractORMPersistenceStrategy 30 | { 31 | public function bidirectionalRelationshipMetadata(string $parent, string $child, string $field): ?RelationshipMetadata 32 | { 33 | $associationMapping = $this->getAssociationMapping($parent, $child, $field); 34 | 35 | if (null === $associationMapping) { 36 | return null; 37 | } 38 | 39 | if (!\is_a( 40 | $child, 41 | $associationMapping['targetEntity'], 42 | allow_string: true 43 | )) { // is_a() handles inheritance as well 44 | throw new \LogicException("Cannot find correct association named \"{$field}\" between classes [parent: \"{$parent}\", child: \"{$child}\"]"); 45 | } 46 | 47 | $inverseField = $associationMapping['isOwningSide'] ? $associationMapping['inversedBy'] ?? null : $associationMapping['mappedBy'] ?? null; 48 | 49 | if (null === $inverseField) { 50 | return null; 51 | } 52 | 53 | return match (true) { 54 | ClassMetadataInfo::ONE_TO_MANY === $associationMapping['type'] => new OneToManyRelationship( 55 | inverseField: $inverseField, 56 | collectionIndexedBy: $associationMapping['indexBy'] ?? null 57 | ), 58 | ClassMetadataInfo::ONE_TO_ONE === $associationMapping['type'] => new OneToOneRelationship( 59 | inverseField: $inverseField, 60 | isOwning: $associationMapping['isOwningSide'] ?? false 61 | ), 62 | ClassMetadataInfo::MANY_TO_ONE === $associationMapping['type'] => new ManyToOneRelationship( 63 | inverseField: $inverseField, 64 | ), 65 | default => null, 66 | }; 67 | } 68 | 69 | /** 70 | * @param class-string $entityClass 71 | * @return array[]|null 72 | * @phpstan-return AssociationMapping|null 73 | */ 74 | private function getAssociationMapping(string $entityClass, string $targetEntity, string $field): ?array 75 | { 76 | try { 77 | $associationMapping = $this->objectManagerFor($entityClass)->getClassMetadata($entityClass)->getAssociationMapping($field); 78 | } catch (MappingException|ORMMappingException) { 79 | return null; 80 | } 81 | 82 | if (!\is_a($targetEntity, $associationMapping['targetEntity'], allow_string: true)) { 83 | return null; 84 | } 85 | 86 | return $associationMapping; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Maker/Factory/LegacyORMDefaultPropertiesGuesser.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Maker\Factory; 15 | 16 | use Doctrine\ORM\Mapping\ClassMetadataInfo as ORMClassMetadata; 17 | use Symfony\Component\Console\Style\SymfonyStyle; 18 | use Zenstruck\Foundry\ORM\DoctrineOrmVersionGuesser; 19 | use Zenstruck\Foundry\Persistence\Exception\NoPersistenceStrategy; 20 | 21 | /** 22 | * @internal 23 | * @see ORMDefaultPropertiesGuesser 24 | * 25 | * This file is basically a copy/paste of ORMDefaultPropertiesGuesser, but offers doctrine/orm 2 compatibility 26 | */ 27 | final class LegacyORMDefaultPropertiesGuesser extends AbstractDoctrineDefaultPropertiesGuesser 28 | { 29 | public function __invoke(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery): void 30 | { 31 | if (DoctrineOrmVersionGuesser::isOrmV3()) { 32 | return; 33 | } 34 | 35 | $metadata = $this->getClassMetadata($makeFactoryData); 36 | 37 | if (!$metadata instanceof ORMClassMetadata) { 38 | throw new \InvalidArgumentException("\"{$makeFactoryData->getObjectFullyQualifiedClassName()}\" is not a valid ORM class."); 39 | } 40 | 41 | $this->guessDefaultValueForORMAssociativeFields($io, $makeFactoryData, $makeFactoryQuery, $metadata); 42 | $this->guessDefaultValueForEmbedded($io, $makeFactoryData, $makeFactoryQuery, $metadata); 43 | } 44 | 45 | public function supports(MakeFactoryData $makeFactoryData): bool 46 | { 47 | try { 48 | $metadata = $this->getClassMetadata($makeFactoryData); 49 | 50 | return $metadata instanceof ORMClassMetadata; 51 | } catch (NoPersistenceStrategy) { 52 | return false; 53 | } 54 | } 55 | 56 | private function guessDefaultValueForORMAssociativeFields(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery, ORMClassMetadata $metadata): void 57 | { 58 | foreach ($metadata->associationMappings as $item) { 59 | // if joinColumns is not written entity is default nullable ($nullable = true;) 60 | if (true === ($item['joinColumns'][0]['nullable'] ?? true)) { 61 | continue; 62 | } 63 | 64 | if (isset($item['mappedBy']) || isset($item['joinTable'])) { 65 | // we don't want to add defaults for X-To-Many relationships 66 | continue; 67 | } 68 | 69 | $this->addDefaultValueUsingFactory($io, $makeFactoryData, $makeFactoryQuery, $item['fieldName'], $item['targetEntity']); 70 | } 71 | } 72 | 73 | private function guessDefaultValueForEmbedded(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery, ORMClassMetadata $metadata): void 74 | { 75 | foreach ($metadata->embeddedClasses as $fieldName => $item) { 76 | $isNullable = $makeFactoryData->getObject()->getProperty($fieldName)->getType()?->allowsNull() ?? true; 77 | 78 | if (!$makeFactoryQuery->isAllFields() && $isNullable) { 79 | continue; 80 | } 81 | 82 | $this->addDefaultValueUsingFactory($io, $makeFactoryData, $makeFactoryQuery, $fieldName, $item['class']); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /utils/rector/config/foundry-set.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | use Rector\Config\RectorConfig; 15 | use Zenstruck\Foundry\Utils\Rector\ChangeFactoryBaseClassRector; 16 | use Zenstruck\Foundry\Utils\Rector\ChangeProxyParamTypesRector; 17 | use Zenstruck\Foundry\Utils\Rector\ChangeProxyReturnTypesRector; 18 | use Zenstruck\Foundry\Utils\Rector\MethodCallToFuncCallWithObjectAsFirstParameter\MethodCallToFuncCallWithObjectAsFirstParameter; 19 | use Zenstruck\Foundry\Utils\Rector\MethodCallToFuncCallWithObjectAsFirstParameter\MethodCallToFuncCallWithObjectAsFirstParameterRector; 20 | use Zenstruck\Foundry\Utils\Rector\RemoveFunctionCall\RemoveFunctionCall; 21 | use Zenstruck\Foundry\Utils\Rector\RemoveFunctionCall\RemoveFunctionCallRector; 22 | use Zenstruck\Foundry\Utils\Rector\RemoveMethodCall\RemoveMethodCall; 23 | use Zenstruck\Foundry\Utils\Rector\RemoveMethodCall\RemoveMethodCallRector; 24 | use Zenstruck\Foundry\Utils\Rector\RemovePhpDocProxyTypeHintRector; 25 | use Zenstruck\Foundry\Utils\Rector\RemoveUnproxifyArrayMapRector; 26 | use Zenstruck\Foundry\Utils\Rector\RemoveWithoutAutorefreshCallRector; 27 | 28 | return static function(RectorConfig $rectorConfig): void { 29 | if (\PHP_VERSION_ID < 80400) { 30 | throw new LogicException('Cannot use Foundry rector suite with PHP < 8.4'); 31 | } 32 | 33 | $rectorConfig->ruleWithConfiguration( 34 | MethodCallToFuncCallWithObjectAsFirstParameterRector::class, 35 | [ 36 | new MethodCallToFuncCallWithObjectAsFirstParameter('_get', 'Zenstruck\Foundry\get'), 37 | new MethodCallToFuncCallWithObjectAsFirstParameter('_set', 'Zenstruck\Foundry\set'), 38 | 39 | new MethodCallToFuncCallWithObjectAsFirstParameter('_save', 'Zenstruck\Foundry\Persistence\save'), 40 | new MethodCallToFuncCallWithObjectAsFirstParameter('_refresh', 'Zenstruck\Foundry\Persistence\refresh'), 41 | new MethodCallToFuncCallWithObjectAsFirstParameter('_delete', 'Zenstruck\Foundry\Persistence\delete'), 42 | 43 | new MethodCallToFuncCallWithObjectAsFirstParameter('_assertPersisted', 'Zenstruck\Foundry\Persistence\assert_persisted'), 44 | new MethodCallToFuncCallWithObjectAsFirstParameter('_assertNotPersisted', 'Zenstruck\Foundry\Persistence\assert_not_persisted'), 45 | 46 | new MethodCallToFuncCallWithObjectAsFirstParameter('_repository', 'Zenstruck\Foundry\Persistence\repository'), 47 | ] 48 | ); 49 | 50 | $rectorConfig->ruleWithConfiguration( 51 | RemoveMethodCallRector::class, 52 | [ 53 | new RemoveMethodCall('_enableAutoRefresh'), 54 | new RemoveMethodCall('_disableAutoRefresh'), 55 | new RemoveMethodCall('_real'), 56 | ] 57 | ); 58 | 59 | $rectorConfig->ruleWithConfiguration( 60 | RemoveFunctionCallRector::class, 61 | [ 62 | new RemoveFunctionCall('Zenstruck\Foundry\Persistence\proxy'), 63 | new RemoveFunctionCall('Zenstruck\Foundry\Persistence\unproxy'), 64 | ] 65 | ); 66 | 67 | $rectorConfig->rules([ 68 | RemoveWithoutAutorefreshCallRector::class, 69 | ChangeFactoryBaseClassRector::class, 70 | ChangeProxyParamTypesRector::class, 71 | ChangeProxyReturnTypesRector::class, 72 | RemovePhpDocProxyTypeHintRector::class, 73 | RemoveUnproxifyArrayMapRector::class, 74 | ]); 75 | }; 76 | -------------------------------------------------------------------------------- /src/Persistence/ResetDatabase/ResetDatabaseManager.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\Foundry\Persistence\ResetDatabase; 15 | 16 | use DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver; 17 | use Symfony\Component\HttpKernel\KernelInterface; 18 | use Zenstruck\Foundry\Configuration; 19 | use Zenstruck\Foundry\Exception\PersistenceNotAvailable; 20 | use Zenstruck\Foundry\Persistence\PersistenceManager; 21 | use Zenstruck\Foundry\Tests\Fixture\TestKernel; 22 | 23 | /** 24 | * @internal 25 | * @author Nicolas PHILIPPE 26 | */ 27 | final class ResetDatabaseManager 28 | { 29 | private static bool $hasDatabaseBeenReset = false; 30 | 31 | /** 32 | * @param iterable $beforeFirstTestResetters 33 | * @param iterable $beforeEachTestResetter 34 | */ 35 | public function __construct( 36 | private iterable $beforeFirstTestResetters, 37 | private iterable $beforeEachTestResetter, 38 | ) { 39 | } 40 | 41 | /** 42 | * @param callable():KernelInterface $createKernel 43 | * @param callable():void $shutdownKernel 44 | */ 45 | public static function resetBeforeFirstTest(callable $createKernel, callable $shutdownKernel): void 46 | { 47 | if (self::$hasDatabaseBeenReset) { 48 | return; 49 | } 50 | 51 | $kernel = $createKernel(); 52 | $configuration = Configuration::instance(); 53 | 54 | try { 55 | $databaseResetters = $configuration->persistence()->resetDatabaseManager()->beforeFirstTestResetters; 56 | } catch (PersistenceNotAvailable $e) { 57 | if (!\class_exists(TestKernel::class)) { 58 | throw $e; 59 | } 60 | 61 | // allow this to fail if running foundry test suite 62 | return; 63 | } 64 | 65 | foreach ($databaseResetters as $databaseResetter) { 66 | $databaseResetter->resetBeforeFirstTest($kernel); 67 | } 68 | 69 | $shutdownKernel(); 70 | 71 | self::$hasDatabaseBeenReset = true; 72 | } 73 | 74 | /** 75 | * @param callable():KernelInterface $createKernel 76 | * @param callable():void $shutdownKernel 77 | */ 78 | public static function resetBeforeEachTest(callable $createKernel, callable $shutdownKernel): void 79 | { 80 | if (self::canSkipSchemaReset()) { 81 | // can fully skip booting the kernel 82 | return; 83 | } 84 | 85 | $kernel = $createKernel(); 86 | $configuration = Configuration::instance(); 87 | 88 | try { 89 | $beforeEachTestResetters = $configuration->persistence()->resetDatabaseManager()->beforeEachTestResetter; 90 | } catch (PersistenceNotAvailable $e) { 91 | if (!\class_exists(TestKernel::class)) { 92 | throw $e; 93 | } 94 | 95 | // allow this to fail if running foundry test suite 96 | return; 97 | } 98 | 99 | foreach ($beforeEachTestResetters as $beforeEachTestResetter) { 100 | $beforeEachTestResetter->resetBeforeEachTest($kernel); 101 | } 102 | 103 | $configuration->stories->loadGlobalStories(); 104 | 105 | $shutdownKernel(); 106 | } 107 | 108 | public static function isDAMADoctrineTestBundleEnabled(): bool 109 | { 110 | return \class_exists(StaticDriver::class) && StaticDriver::isKeepStaticConnections(); 111 | } 112 | 113 | private static function canSkipSchemaReset(): bool 114 | { 115 | return PersistenceManager::isOrmOnly() && self::isDAMADoctrineTestBundleEnabled(); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Maker/MakeStory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker; 13 | 14 | use Symfony\Bundle\MakerBundle\ConsoleStyle; 15 | use Symfony\Bundle\MakerBundle\DependencyBuilder; 16 | use Symfony\Bundle\MakerBundle\Generator; 17 | use Symfony\Bundle\MakerBundle\InputConfiguration; 18 | use Symfony\Bundle\MakerBundle\Maker\AbstractMaker; 19 | use Symfony\Bundle\MakerBundle\Validator; 20 | use Symfony\Component\Console\Command\Command; 21 | use Symfony\Component\Console\Input\InputArgument; 22 | use Symfony\Component\Console\Input\InputInterface; 23 | use Symfony\Component\Console\Input\InputOption; 24 | 25 | /** 26 | * @author Kevin Bond 27 | * 28 | * @internal 29 | */ 30 | final class MakeStory extends AbstractMaker 31 | { 32 | public function __construct( 33 | private NamespaceGuesser $namespaceGuesser, 34 | private string $defaultNamespace, 35 | ) { 36 | } 37 | 38 | public static function getCommandName(): string 39 | { 40 | return 'make:story'; 41 | } 42 | 43 | public static function getCommandDescription(): string 44 | { 45 | return 'Creates a Foundry story'; 46 | } 47 | 48 | public function configureCommand(Command $command, InputConfiguration $inputConfig): void 49 | { 50 | $command 51 | ->setDescription(self::getCommandDescription()) 52 | ->addArgument('name', InputArgument::OPTIONAL, 'The name of the story class (e.g. DefaultCategoriesStory)') 53 | ->addOption('test', null, InputOption::VALUE_NONE, 'Create in tests/ instead of src/') 54 | ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Customize the namespace for generated factories') 55 | ; 56 | 57 | $inputConfig->setArgumentAsNonInteractive('name'); 58 | } 59 | 60 | public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void 61 | { 62 | if ($input->getArgument('name')) { 63 | return; 64 | } 65 | 66 | if (!$input->getOption('test')) { 67 | $io->text('// Note: pass --test if you want to generate stories in your tests/ directory'); 68 | $io->newLine(); 69 | } 70 | 71 | $argument = $command->getDefinition()->getArgument('name'); 72 | $value = $io->ask($argument->getDescription(), null, static fn(?string $value = null): string => Validator::notBlank($value)); // @phpstan-ignore staticMethod.internalClass 73 | $input->setArgument($argument->getName(), $value); 74 | } 75 | 76 | public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void 77 | { 78 | $class = $input->getArgument('name'); 79 | $namespace = ($this->namespaceGuesser)($generator, $class, $input->getOption('namespace') ?? $this->defaultNamespace, $input->getOption('test')); 80 | 81 | $storyClassNameDetails = $generator->createClassNameDetails( 82 | $input->getArgument('name'), 83 | $namespace, 84 | 'Story' 85 | ); 86 | 87 | $generator->generateClass( 88 | $storyClassNameDetails->getFullName(), 89 | __DIR__.'/../../skeleton/Story.tpl.php', 90 | [] 91 | ); 92 | 93 | $generator->writeChanges(); 94 | 95 | $this->writeSuccessMessage($io); 96 | 97 | $io->text([ 98 | 'Next: Open your story class and start customizing it.', 99 | 'Find the documentation at https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#stories', 100 | ]); 101 | } 102 | 103 | public function configureDependencies(DependencyBuilder $dependencies): void 104 | { 105 | // noop 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenstruck/foundry", 3 | "description": "A model factory library for creating expressive, auto-completable, on-demand dev/test fixtures with Symfony and Doctrine.", 4 | "homepage": "https://github.com/zenstruck/foundry", 5 | "type": "library", 6 | "license": "MIT", 7 | "keywords": ["fixture", "factory", "test", "symfony", "faker", "doctrine", "dev"], 8 | "authors": [ 9 | { 10 | "name": "Kevin Bond", 11 | "email": "kevinbond@gmail.com" 12 | }, 13 | { 14 | "name": "Nicolas PHILIPPE", 15 | "email": "nikophil@gmail.com" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1", 20 | "fakerphp/faker": "^1.23", 21 | "symfony/deprecation-contracts": "^2.2|^3.0", 22 | "symfony/polyfill-php84": "^1.32", 23 | "symfony/property-access": "^6.4|^7.0|^8.0", 24 | "symfony/property-info": "^6.4|^7.0|^8.0", 25 | "symfony/var-exporter": "^6.4.9|~7.0.9|^7.1.2|^8.0", 26 | "zenstruck/assert": "^1.4" 27 | }, 28 | "require-dev": { 29 | "bamarni/composer-bin-plugin": "^1.8", 30 | "dama/doctrine-test-bundle": "^8.0", 31 | "doctrine/collections": "^1.7|^2.0", 32 | "doctrine/common": "^3.2.2", 33 | "doctrine/doctrine-bundle": "^2.10|^3.0", 34 | "doctrine/doctrine-migrations-bundle": "^2.2|^3.0", 35 | "doctrine/mongodb-odm": "^2.4", 36 | "doctrine/mongodb-odm-bundle": "^4.6|^5.0", 37 | "doctrine/orm": "^2.16|^3.0", 38 | "doctrine/persistence": "^2.0|^3.0|^4.0", 39 | "phpunit/phpunit": "^9.5.0 || ^10.0 || ^11.0 || ^12.0", 40 | "symfony/browser-kit": "^6.4|^7.0|^8.0", 41 | "symfony/console": "^6.4|^7.0|^8.0", 42 | "symfony/dotenv": "^6.4|^7.0|^8.0", 43 | "symfony/event-dispatcher": "^6.4|^7.0|^8.0", 44 | "symfony/framework-bundle": "^6.4|^7.0|^8.0", 45 | "symfony/maker-bundle": "^1.55", 46 | "symfony/phpunit-bridge": "^6.4.26|^7.0|^8.0", 47 | "symfony/routing": "^6.4|^7.0|^8.0", 48 | "symfony/runtime": "^6.4|^7.0|^8.0", 49 | "symfony/translation-contracts": "^3.4", 50 | "symfony/uid": "^6.4|^7.0|^8.0", 51 | "symfony/var-dumper": "^6.4|^7.0|^8.0", 52 | "symfony/yaml": "^6.4|^7.0|^8.0", 53 | "webmozart/assert": "^1.11" 54 | }, 55 | "autoload": { 56 | "psr-4": { 57 | "Zenstruck\\Foundry\\": "src/", 58 | "Zenstruck\\Foundry\\Psalm\\": "utils/psalm", 59 | "Zenstruck\\Foundry\\Utils\\Rector\\": "utils/rector/src/" 60 | }, 61 | "files": [ 62 | "src/functions.php", 63 | "src/Persistence/functions.php", 64 | "src/symfony_console.php" 65 | ] 66 | }, 67 | "autoload-dev": { 68 | "psr-4": { 69 | "Zenstruck\\Foundry\\Tests\\": ["tests/"], 70 | "App\\": "tests/Fixture/Maker/tmp/src", 71 | "App\\Tests\\": "tests/Fixture/Maker/tmp/tests", 72 | "Zenstruck\\Foundry\\Utils\\Rector\\Tests\\": "utils/rector/tests/" 73 | }, 74 | "exclude-from-classmap": ["tests/Fixture/Maker/expected", "utils/rector/tests/**/Fixtures/"] 75 | }, 76 | "config": { 77 | "preferred-install": "dist", 78 | "sort-packages": true, 79 | "allow-plugins": { 80 | "bamarni/composer-bin-plugin": true, 81 | "symfony/flex": true, 82 | "symfony/runtime": false 83 | } 84 | }, 85 | "conflict": { 86 | "doctrine/persistence": "<2.0", 87 | "doctrine/cache": "<1.12.1" 88 | }, 89 | "extra": { 90 | "bamarni-bin": { 91 | "target-directory": "bin/tools", 92 | "bin-links": true, 93 | "forward-command": false 94 | }, 95 | "psalm": { 96 | "pluginClass": "Zenstruck\\Foundry\\Psalm\\FoundryPlugin" 97 | } 98 | }, 99 | "scripts": { 100 | "post-install-cmd": ["@composer bin phpstan install", "@composer bin phpbench install"] 101 | }, 102 | "minimum-stability": "dev", 103 | "prefer-stable": true 104 | } 105 | -------------------------------------------------------------------------------- /src/Maker/Factory/ObjectDefaultPropertiesGuesser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker\Factory; 13 | 14 | use Symfony\Component\Console\Style\SymfonyStyle; 15 | 16 | /** 17 | * @internal 18 | */ 19 | class ObjectDefaultPropertiesGuesser extends AbstractDefaultPropertyGuesser 20 | { 21 | private const DEFAULTS_FOR_NOT_PERSISTED = [ 22 | 'array' => '[],', 23 | 'string' => 'self::faker()->sentence(),', 24 | 'int' => 'self::faker()->randomNumber(),', 25 | 'float' => 'self::faker()->randomFloat(),', 26 | 'bool' => 'self::faker()->boolean(),', 27 | \DateTime::class => 'self::faker()->dateTime(),', 28 | \DateTimeImmutable::class => '\DateTimeImmutable::createFromMutable(self::faker()->dateTime()),', 29 | ]; 30 | 31 | public function __invoke(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery): void 32 | { 33 | foreach ($makeFactoryData->getObject()->getProperties() as $property) { 34 | if (!$this->shouldAddPropertyToFactory($makeFactoryQuery, $property)) { 35 | continue; 36 | } 37 | 38 | $type = $this->getPropertyType($property) ?? ''; 39 | 40 | $value = \sprintf('null, // TODO add %svalue manually', $type ? "{$type} " : ''); 41 | 42 | if (\PHP_VERSION_ID >= 80100 && \enum_exists($type)) { 43 | $makeFactoryData->addEnumDefaultProperty($property->getName(), $type); 44 | 45 | continue; 46 | } 47 | 48 | if ($type && \class_exists($type) && !\is_a($type, \DateTimeInterface::class, true)) { 49 | $this->addDefaultValueUsingFactory($io, $makeFactoryData, $makeFactoryQuery, $property->getName(), $type); 50 | 51 | continue; 52 | } 53 | 54 | if (\array_key_exists($type, self::DEFAULTS_FOR_NOT_PERSISTED)) { 55 | $value = self::DEFAULTS_FOR_NOT_PERSISTED[$type]; 56 | } 57 | 58 | $makeFactoryData->addDefaultProperty($property->getName(), $value); 59 | } 60 | } 61 | 62 | public function supports(MakeFactoryData $makeFactoryData): bool 63 | { 64 | return !$makeFactoryData->isPersisted(); 65 | } 66 | 67 | private function shouldAddPropertyToFactory(MakeFactoryQuery $makeFactoryQuery, \ReflectionProperty $property): bool 68 | { 69 | // if option "--all-fields" was passed 70 | if ($makeFactoryQuery->isAllFields()) { 71 | return true; 72 | } 73 | 74 | // if property is inside constructor, check if it has a default value 75 | if ($constructorParameter = $this->getConstructorParameterForProperty($property)) { 76 | return !$constructorParameter->isDefaultValueAvailable(); 77 | } 78 | 79 | // if the property has a default value, we should not add it to the factory 80 | if ($property->hasDefaultValue()) { 81 | return false; 82 | } 83 | 84 | // if property has type, we need to add it to the factory 85 | return $property->hasType(); 86 | } 87 | 88 | private function getPropertyType(\ReflectionProperty $property): ?string 89 | { 90 | if (!$property->hasType()) { 91 | $type = $this->getConstructorParameterForProperty($property)?->getType(); 92 | } else { 93 | $type = $property->getType(); 94 | } 95 | 96 | if (!$type instanceof \ReflectionNamedType) { 97 | return null; 98 | } 99 | 100 | return $type->getName(); 101 | } 102 | 103 | private function getConstructorParameterForProperty(\ReflectionProperty $property): ?\ReflectionParameter 104 | { 105 | if ($constructor = $property->getDeclaringClass()->getConstructor()) { 106 | foreach ($constructor->getParameters() as $parameter) { 107 | if ($parameter->getName() === $property->getName()) { 108 | return $parameter; 109 | } 110 | } 111 | } 112 | 113 | return null; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Maker/Factory/NoPersistenceObjectsAutoCompleter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker\Factory; 13 | 14 | /** 15 | * @internal 16 | */ 17 | final class NoPersistenceObjectsAutoCompleter 18 | { 19 | public function __construct(private string $kernelRootDir) 20 | { 21 | } 22 | 23 | /** 24 | * @return list 25 | */ 26 | public function getAutocompleteValues(): array 27 | { 28 | $classes = []; 29 | 30 | $excludedFiles = $this->excludedFiles(); 31 | 32 | foreach ($this->getDefinedNamespaces() as $namespacePrefix => $rootFragment) { 33 | $allFiles = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($rootPath = "{$this->kernelRootDir}/{$rootFragment}")); 34 | 35 | /** @var \SplFileInfo $phpFile */ 36 | foreach (new \RegexIterator($allFiles, '/\.php$/') as $phpFile) { 37 | if (\in_array($phpFile->getRealPath(), $excludedFiles, true)) { 38 | continue; 39 | } 40 | 41 | $class = $this->toPSR4($rootPath, $phpFile, $namespacePrefix); 42 | 43 | if (\in_array($class, ['Zenstruck\Foundry\Proxy', 'Zenstruck\Foundry\RepositoryProxy', 'Zenstruck\Foundry\RepositoryAssertions'])) { 44 | // do not load legacy Proxy: prevents deprecations in tests. 45 | continue; 46 | } 47 | 48 | try { 49 | // @phpstan-ignore-next-line $class is not always a class-string 50 | $reflection = new \ReflectionClass($class); 51 | } catch (\Throwable) { 52 | // remove all files which are not class / interface / traits 53 | continue; 54 | } 55 | 56 | /** @var class-string $class */ 57 | if (!$reflection->isInstantiable()) { 58 | // remove abstract classes / interfaces / traits 59 | continue; 60 | } 61 | 62 | $classes[] = $class; 63 | } 64 | } 65 | 66 | \sort($classes); 67 | 68 | return $classes; 69 | } 70 | 71 | private function toPSR4(string $rootPath, \SplFileInfo $fileInfo, string $namespacePrefix): string 72 | { 73 | // /app/src/Bundle/Maker/Factory/NoPersistenceObjectsAutoCompleter.php => /Bundle/Maker/Factory/NoPersistenceObjectsAutoCompleter 74 | $relativeFileNameWithoutExtension = \str_replace([$rootPath, '.php'], ['', ''], $fileInfo->getRealPath()); 75 | 76 | return $namespacePrefix.\str_replace('/', '\\', $relativeFileNameWithoutExtension); 77 | } 78 | 79 | /** 80 | * @return array 81 | */ 82 | private function getDefinedNamespaces(): array 83 | { 84 | $composerConfig = $this->getComposerConfiguration(); 85 | 86 | /** @var array $definedNamespaces */ 87 | $definedNamespaces = $composerConfig['autoload']['psr-4'] ?? []; 88 | 89 | return \array_combine( 90 | \array_map( 91 | static fn(string $namespacePrefix): string => \trim($namespacePrefix, '\\'), 92 | \array_keys($definedNamespaces), 93 | ), 94 | \array_map( 95 | static fn(string $rootFragment): string => \trim($rootFragment, '/'), 96 | \array_values($definedNamespaces), 97 | ), 98 | ); 99 | } 100 | 101 | /** 102 | * @return array 103 | */ 104 | private function excludedFiles(): array 105 | { 106 | $composerConfig = $this->getComposerConfiguration(); 107 | 108 | return \array_map( 109 | fn(string $file): string => "{$this->kernelRootDir}/{$file}", 110 | $composerConfig['autoload']['files'] ?? [], 111 | ); 112 | } 113 | 114 | /** 115 | * @return array 116 | */ 117 | private function getComposerConfiguration(): array 118 | { 119 | $composerConfigFilePath = "{$this->kernelRootDir}/composer.json"; 120 | if (!\is_file($composerConfigFilePath)) { 121 | return []; 122 | } 123 | 124 | return \json_decode((string) \file_get_contents($composerConfigFilePath), true, 512, \JSON_THROW_ON_ERROR); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Persistence/RepositoryAssertions.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Persistence; 13 | 14 | use Doctrine\Persistence\ObjectRepository; 15 | use Zenstruck\Assert; 16 | use Zenstruck\Foundry\Factory; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @phpstan-import-type Parameters from Factory 22 | */ 23 | final class RepositoryAssertions 24 | { 25 | /** 26 | * @internal 27 | * 28 | * @param RepositoryDecorator> $repository 29 | */ 30 | public function __construct(private RepositoryDecorator $repository) 31 | { 32 | } 33 | 34 | /** 35 | * @phpstan-param Parameters $criteria 36 | */ 37 | public function empty(array $criteria = [], string $message = 'Expected {entity} repository to be empty but it has {actual} items.'): self 38 | { 39 | return $this->count(0, $criteria, $message); 40 | } 41 | 42 | /** 43 | * @phpstan-param Parameters $criteria 44 | */ 45 | public function notEmpty(array $criteria = [], string $message = 'Expected {entity} repository to NOT be empty but it is.'): self 46 | { 47 | Assert::that($this->repository->count($criteria)) 48 | ->isNot(0, $message, ['entity' => $this->repository->getClassName()]) 49 | ; 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * @phpstan-param Parameters $criteria 56 | */ 57 | public function count(int $expectedCount, array $criteria = [], string $message = 'Expected count of {entity} to be {expected} (actual: {actual}).'): self 58 | { 59 | Assert::that($this->repository->count($criteria)) 60 | ->is($expectedCount, $message, ['entity' => $this->repository->getClassName()]) 61 | ; 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * @phpstan-param Parameters $criteria 68 | */ 69 | public function countGreaterThan(int $expected, array $criteria = [], string $message = 'Expected count of {entity} to be greater than {expected} (actual: {actual}).'): self 70 | { 71 | Assert::that($this->repository->count($criteria)) 72 | ->isGreaterThan($expected, $message, ['entity' => $this->repository->getClassName()]) 73 | ; 74 | 75 | return $this; 76 | } 77 | 78 | /** 79 | * @phpstan-param Parameters $criteria 80 | */ 81 | public function countGreaterThanOrEqual(int $expected, array $criteria = [], string $message = 'Expected count of {entity} to be greater than or equal {expected} (actual: {actual}).'): self 82 | { 83 | Assert::that($this->repository->count($criteria)) 84 | ->isGreaterThanOrEqualTo($expected, $message, ['entity' => $this->repository->getClassName()]) 85 | ; 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * @phpstan-param Parameters $criteria 92 | */ 93 | public function countLessThan(int $expected, array $criteria = [], string $message = 'Expected count of {entity} to be less than {expected} (actual: {actual}).'): self 94 | { 95 | Assert::that($this->repository->count($criteria)) 96 | ->isLessThan($expected, $message, ['entity' => $this->repository->getClassName()]) 97 | ; 98 | 99 | return $this; 100 | } 101 | 102 | /** 103 | * @phpstan-param Parameters $criteria 104 | */ 105 | public function countLessThanOrEqual(int $expected, array $criteria = [], string $message = 'Expected count of {entity} to be less than or equal {expected} (actual: {actual}).'): self 106 | { 107 | Assert::that($this->repository->count($criteria)) 108 | ->isLessThanOrEqualTo($expected, $message, ['entity' => $this->repository->getClassName()]) 109 | ; 110 | 111 | return $this; 112 | } 113 | 114 | public function exists(mixed $criteria, string $message = 'Expected {entity} to exist but it does not.'): self 115 | { 116 | Assert::that($this->repository->find($criteria))->isNotEmpty($message, [ 117 | 'entity' => $this->repository->getClassName(), 118 | 'criteria' => $criteria, 119 | ]); 120 | 121 | return $this; 122 | } 123 | 124 | public function notExists(mixed $criteria, string $message = 'Expected {entity} to not exist but it does.'): self 125 | { 126 | Assert::that($this->repository->find($criteria))->isEmpty($message, [ 127 | 'entity' => $this->repository->getClassName(), 128 | 'criteria' => $criteria, 129 | ]); 130 | 131 | return $this; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Command/LoadFixturesCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Command; 13 | 14 | use DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver; 15 | use Symfony\Component\Console\Command\Command; 16 | use Symfony\Component\Console\Exception\InvalidArgumentException; 17 | use Symfony\Component\Console\Exception\LogicException; 18 | use Symfony\Component\Console\Input\InputArgument; 19 | use Symfony\Component\Console\Input\InputInterface; 20 | use Symfony\Component\Console\Input\InputOption; 21 | use Symfony\Component\Console\Output\OutputInterface; 22 | use Symfony\Component\Console\Style\SymfonyStyle; 23 | use Symfony\Component\HttpKernel\KernelInterface; 24 | use Zenstruck\Foundry\Persistence\ResetDatabase\BeforeFirstTestResetter; 25 | use Zenstruck\Foundry\Story; 26 | 27 | /** 28 | * @author Nicolas PHILIPPE 29 | * 30 | * @internal 31 | */ 32 | final class LoadFixturesCommand extends Command 33 | { 34 | public function __construct( 35 | /** @var array> */ 36 | private readonly array $stories, 37 | /** @var array>> */ 38 | private readonly array $groupedStories, 39 | /** @var iterable */ 40 | private iterable $databaseResetters, 41 | private KernelInterface $kernel, 42 | ) { 43 | parent::__construct(); 44 | } 45 | 46 | protected function configure(): void 47 | { 48 | $this 49 | ->addArgument('name', InputArgument::OPTIONAL, 'The name of the story to load.') 50 | ->addOption('append', 'a', InputOption::VALUE_NONE, 'Skip resetting database and append data to the existing database.') 51 | ; 52 | } 53 | 54 | protected function execute(InputInterface $input, OutputInterface $output): int 55 | { 56 | if (0 === \count($this->stories)) { 57 | throw new LogicException('No story as fixture available: add attribute #[AsFixture] to your story classes before running this command.'); 58 | } 59 | 60 | $io = new SymfonyStyle($input, $output); 61 | 62 | if (!$input->getOption('append')) { 63 | if (!$io->confirm('The database will be recreated! Do you want to continue?')) { 64 | $io->warning('Aborting command execution. Use the --append option to skip database reset.'); 65 | 66 | return self::SUCCESS; 67 | } 68 | 69 | $this->resetDatabase(); 70 | } 71 | 72 | $stories = []; 73 | 74 | if (null === ($name = $input->getArgument('name'))) { 75 | if (1 === \count($this->stories)) { 76 | $name = \array_keys($this->stories)[0]; 77 | } else { 78 | $storyNames = \array_keys($this->stories); 79 | if (\count($this->groupedStories) > 0) { 80 | $storyNames[] = '(choose a group of stories...)'; 81 | } 82 | $name = $io->choice('Choose a story to load:', $storyNames); 83 | } 84 | 85 | if (!isset($this->stories[$name])) { 86 | $groupsNames = \array_keys($this->groupedStories); 87 | $name = $io->choice('Choose a group of stories:', $groupsNames); 88 | } 89 | } 90 | 91 | if (isset($this->stories[$name])) { 92 | $io->comment("Loading story with name \"{$name}\"..."); 93 | $stories = [$name => $this->stories[$name]]; 94 | } 95 | 96 | if (isset($this->groupedStories[$name])) { 97 | $io->comment("Loading stories group \"{$name}\"..."); 98 | $stories = $this->groupedStories[$name]; 99 | } 100 | 101 | if (!$stories) { 102 | throw new InvalidArgumentException("Story with name \"{$name}\" does not exist."); 103 | } 104 | 105 | foreach ($stories as $name => $storyClass) { 106 | $storyClass::load(); 107 | 108 | if ($io->isVerbose()) { 109 | $io->info("Story \"{$storyClass}\" loaded (name: {$name})."); 110 | } 111 | } 112 | 113 | $io->success('Stories successfully loaded!'); 114 | 115 | return self::SUCCESS; 116 | } 117 | 118 | private function resetDatabase(): void 119 | { 120 | // it is very not likely that we need dama when running this command 121 | if (\class_exists(StaticDriver::class) && StaticDriver::isKeepStaticConnections()) { 122 | StaticDriver::setKeepStaticConnections(false); 123 | } 124 | 125 | foreach ($this->databaseResetters as $databaseResetter) { 126 | $databaseResetter->resetBeforeFirstTest($this->kernel); 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Maker/Factory/MakeFactoryPHPDocMethod.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker\Factory; 13 | 14 | use Doctrine\ODM\MongoDB\Repository\DocumentRepository; 15 | 16 | /** 17 | * @internal 18 | */ 19 | final class MakeFactoryPHPDocMethod 20 | { 21 | // @phpstan-ignore-next-line 22 | public function __construct(private string $objectName, private string $prototype, private bool $returnsCollection, private bool $isStatic = true, private ?\ReflectionClass $repository = null) 23 | { 24 | } 25 | 26 | /** @return non-empty-list */ 27 | public static function createAll(MakeFactoryData $makeFactoryData): array 28 | { 29 | $methods = []; 30 | 31 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'create(array|callable $attributes = [])', returnsCollection: false, isStatic: false); 32 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'createOne(array $attributes = [])', returnsCollection: false); 33 | 34 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'createMany(int $number, array|callable $attributes = [])', returnsCollection: true); 35 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'createSequence(iterable|callable $sequence)', returnsCollection: true); 36 | 37 | if ($makeFactoryData->isPersisted()) { 38 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'find(object|array|mixed $criteria)', returnsCollection: false); 39 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'findOrCreate(array $attributes)', returnsCollection: false); 40 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'first(string $sortBy = \'id\')', returnsCollection: false); 41 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'last(string $sortBy = \'id\')', returnsCollection: false); 42 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'random(array $attributes = [])', returnsCollection: false); 43 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'randomOrCreate(array $attributes = [])', returnsCollection: false); 44 | 45 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'all()', returnsCollection: true); 46 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'findBy(array $attributes)', returnsCollection: true); 47 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'randomRange(int $min, int $max, array $attributes = [])', returnsCollection: true); 48 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'randomRangeOrCreate(int $min, int $max, array $attributes = [])', returnsCollection: true); 49 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'randomSet(int $number, array $attributes = [])', returnsCollection: true); 50 | 51 | if (null !== $makeFactoryData->getRepositoryReflectionClass()) { 52 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'repository()', returnsCollection: false, repository: $makeFactoryData->getRepositoryReflectionClass()); 53 | } 54 | } 55 | 56 | return $methods; 57 | } 58 | 59 | public function toString(?string $staticAnalysisTool = null): string 60 | { 61 | $annotation = $staticAnalysisTool ? "{$staticAnalysisTool}-method" : 'method'; 62 | $static = $this->isStatic ? 'static' : ' '; 63 | 64 | if ($this->repository) { 65 | $returnType = match ((bool) $staticAnalysisTool) { 66 | false => "{$this->repository->getShortName()}|ProxyRepositoryDecorator", 67 | true => \sprintf( 68 | "ProxyRepositoryDecorator<{$this->objectName}, %s>", 69 | \is_a($this->repository->getName(), DocumentRepository::class, allow_string: true) 70 | ? "DocumentRepository<{$this->objectName}>" 71 | : "EntityRepository<{$this->objectName}>" 72 | ), 73 | }; 74 | } else { 75 | $returnType = match ([$this->returnsCollection, (bool) $staticAnalysisTool]) { 76 | [true, true] => "list<{$this->objectName}&Proxy<{$this->objectName}>>", 77 | [true, false] => "{$this->objectName}[]|Proxy[]", 78 | [false, true] => "{$this->objectName}&Proxy<{$this->objectName}>", 79 | [false, false] => "{$this->objectName}|Proxy", 80 | }; 81 | } 82 | 83 | return " * @{$annotation} {$static} {$returnType} {$this->prototype}"; 84 | } 85 | 86 | public function sortValue(): string 87 | { 88 | return \sprintf( 89 | "returnsCollection:%s, prototype:{$this->prototype}", 90 | $this->returnsCollection ? '1' : '0', 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Maker/Factory/DoctrineScalarFieldsDefaultPropertiesGuesser.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Foundry\Maker\Factory; 13 | 14 | use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as ODMClassMetadata; 15 | use Doctrine\ORM\Mapping\ClassMetadata as ORMClassMetadata; 16 | use Doctrine\ORM\Mapping\FieldMapping; 17 | use Symfony\Component\Console\Style\SymfonyStyle; 18 | use Symfony\Component\Uid\Uuid; 19 | 20 | /** 21 | * @internal 22 | */ 23 | final class DoctrineScalarFieldsDefaultPropertiesGuesser extends AbstractDoctrineDefaultPropertiesGuesser 24 | { 25 | private const DEFAULTS = [ 26 | 'ARRAY' => '[],', 27 | 'ASCII_STRING' => 'self::faker()->text({length}),', 28 | 'BIGINT' => 'self::faker()->randomNumber(),', 29 | 'BLOB' => 'self::faker()->text(),', 30 | 'BOOLEAN' => 'self::faker()->boolean(),', 31 | 'DATE' => 'self::faker()->dateTime(),', 32 | 'DATE_MUTABLE' => 'self::faker()->dateTime(),', 33 | 'DATE_IMMUTABLE' => '\DateTimeImmutable::createFromMutable(self::faker()->dateTime()),', 34 | 'DATETIME' => 'self::faker()->dateTime(),', 35 | 'DATETIME_MUTABLE' => 'self::faker()->dateTime(),', 36 | 'DATETIME_IMMUTABLE' => '\DateTimeImmutable::createFromMutable(self::faker()->dateTime()),', 37 | 'DATETIMETZ_MUTABLE' => 'self::faker()->dateTime(),', 38 | 'DATETIMETZ_IMMUTABLE' => '\DateTimeImmutable::createFromMutable(self::faker()->dateTime()),', 39 | 'DECIMAL' => 'self::faker()->randomFloat(),', 40 | 'FLOAT' => 'self::faker()->randomFloat(),', 41 | 'GUID' => 'self::faker()->uuid(),', 42 | 'INTEGER' => 'self::faker()->randomNumber(),', 43 | 'INT' => 'self::faker()->randomNumber(),', 44 | 'JSON' => '[],', 45 | 'JSON_ARRAY' => '[],', 46 | 'SIMPLE_ARRAY' => '[],', 47 | 'SMALLINT' => 'self::faker()->numberBetween(1, 32767),', 48 | 'STRING' => 'self::faker()->text({length}),', 49 | 'TEXT' => 'self::faker()->text({length}),', 50 | 'TIME_MUTABLE' => 'self::faker()->datetime(),', 51 | 'TIME_IMMUTABLE' => '\DateTimeImmutable::createFromMutable(self::faker()->datetime()),', 52 | 'UUID' => 'Uuid::fromString(self::faker()->uuid()),', 53 | ]; 54 | 55 | public function __invoke(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery): void 56 | { 57 | /** @var ODMClassMetadata|ORMClassMetadata $metadata */ 58 | $metadata = $this->getClassMetadata($makeFactoryData); 59 | 60 | $ids = $metadata->getIdentifierFieldNames(); 61 | 62 | foreach ($metadata->fieldMappings as $property) { 63 | if (\is_array($property) && ($property['embedded'] ?? false)) { 64 | // skip ODM embedded 65 | continue; 66 | } 67 | 68 | $fieldName = $this->extractFieldMappingData($property, 'fieldName'); 69 | 70 | if (\str_contains($fieldName, '.')) { 71 | // this is a "subfield" of an ORM embeddable field. 72 | continue; 73 | } 74 | 75 | // ignore identifiers and nullable fields 76 | if ((!$makeFactoryQuery->isAllFields() && $this->extractFieldMappingData($property, 'nullable', false)) || \in_array($fieldName, $ids, true)) { 77 | continue; 78 | } 79 | 80 | $type = \mb_strtoupper($this->extractFieldMappingData($property, 'type')); 81 | if ($this->extractFieldMappingData($property, 'enumType')) { 82 | $makeFactoryData->addEnumDefaultProperty($fieldName, $this->extractFieldMappingData($property, 'enumType')); 83 | 84 | continue; 85 | } 86 | 87 | $value = "null, // TODO add {$type} type manually"; 88 | $length = $this->extractFieldMappingData($property, 'length', ''); 89 | 90 | if ('UUID' === $type) { 91 | $makeFactoryData->addUse(Uuid::class); 92 | } 93 | 94 | if (\array_key_exists($type, self::DEFAULTS)) { 95 | $value = self::DEFAULTS[$type]; 96 | } 97 | 98 | $makeFactoryData->addDefaultProperty($fieldName, \str_replace('{length}', (string) $length, $value)); 99 | } 100 | } 101 | 102 | public function supports(MakeFactoryData $makeFactoryData): bool 103 | { 104 | return $makeFactoryData->isPersisted(); 105 | } 106 | 107 | // handles both ORM 3 & 4 108 | private function extractFieldMappingData(FieldMapping|array $fieldMapping, string $field, mixed $default = null): mixed 109 | { 110 | if ($fieldMapping instanceof FieldMapping) { 111 | return $fieldMapping->{$field}; 112 | } else { 113 | return $fieldMapping[$field] ?? $default; 114 | } 115 | } 116 | } 117 | --------------------------------------------------------------------------------