├── CHANGELOG.md ├── LICENSE ├── README.md ├── UPGRADE-2.0.md ├── composer.json ├── config ├── command_stubs.php ├── in_memory.php ├── makers.php ├── mongo.php ├── orm.php ├── persistence.php └── services.php ├── docs ├── .doctor-rst.yaml └── index.rst ├── phpbench.json ├── phpunit-deprecation-baseline.xml ├── skeleton ├── Factory.tpl.php └── Story.tpl.php ├── src ├── AnonymousFactoryGenerator.php ├── ArrayFactory.php ├── Attribute │ ├── AsFixture.php │ └── WithStory.php ├── Command │ ├── LoadStoryCommand.php │ └── StubCommand.php ├── Configuration.php ├── DependencyInjection │ └── AsFixtureStoryCompilerPass.php ├── Exception │ ├── CannotCreateFactory.php │ ├── FactoriesTraitNotUsed.php │ ├── FoundryNotBooted.php │ ├── PersistenceDisabled.php │ └── PersistenceNotAvailable.php ├── Factory.php ├── FactoryCollection.php ├── FactoryRegistry.php ├── FactoryRegistryInterface.php ├── ForceValue.php ├── InMemory │ ├── AsInMemoryTest.php │ ├── CannotEnableInMemory.php │ ├── DependencyInjection │ │ └── InMemoryCompilerPass.php │ ├── GenericInMemoryRepository.php │ ├── InMemoryDoctrineObjectRepositoryAdapter.php │ ├── InMemoryFactoryRegistry.php │ ├── InMemoryRepository.php │ ├── InMemoryRepositoryRegistry.php │ └── InMemoryRepositoryTrait.php ├── LazyValue.php ├── Maker │ ├── Factory │ │ ├── AbstractDefaultPropertyGuesser.php │ │ ├── AbstractDoctrineDefaultPropertiesGuesser.php │ │ ├── DefaultPropertiesGuesser.php │ │ ├── DoctrineScalarFieldsDefaultPropertiesGuesser.php │ │ ├── Exception │ │ │ └── FactoryClassAlreadyExistException.php │ │ ├── FactoryCandidatesClassesExtractor.php │ │ ├── FactoryClassMap.php │ │ ├── FactoryGenerator.php │ │ ├── LegacyORMDefaultPropertiesGuesser.php │ │ ├── MakeFactoryData.php │ │ ├── MakeFactoryPHPDocMethod.php │ │ ├── MakeFactoryQuery.php │ │ ├── NoPersistenceObjectsAutoCompleter.php │ │ ├── ODMDefaultPropertiesGuesser.php │ │ ├── ORMDefaultPropertiesGuesser.php │ │ └── ObjectDefaultPropertiesGuesser.php │ ├── MakeFactory.php │ ├── MakeStory.php │ └── NamespaceGuesser.php ├── Mongo │ ├── MongoPersistenceStrategy.php │ ├── MongoResetter.php │ └── MongoSchemaResetter.php ├── ORM │ ├── AbstractORMPersistenceStrategy.php │ ├── DoctrineOrmVersionGuesser.php │ ├── OrmV2PersistenceStrategy.php │ ├── OrmV3PersistenceStrategy.php │ └── ResetDatabase │ │ ├── BaseOrmResetter.php │ │ ├── DamaDatabaseResetter.php │ │ ├── MigrateDatabaseResetter.php │ │ ├── OrmResetter.php │ │ ├── ResetDatabaseMode.php │ │ └── SchemaDatabaseResetter.php ├── Object │ ├── Hydrator.php │ └── Instantiator.php ├── ObjectFactory.php ├── PHPUnit │ ├── BootFoundryOnDataProviderMethodCalled.php │ ├── BuildStoryOnTestPrepared.php │ ├── DisplayFakerSeedOnTestSuiteFinished.php │ ├── EnableInMemoryBeforeTest.php │ ├── FoundryExtension.php │ └── ShutdownFoundryOnDataProviderMethodFinished.php ├── Persistence │ ├── Exception │ │ ├── NoPersistenceStrategy.php │ │ ├── NotEnoughObjects.php │ │ └── RefreshObjectFailed.php │ ├── IsProxy.php │ ├── PersistMode.php │ ├── PersistenceManager.php │ ├── PersistenceStrategy.php │ ├── PersistentObjectFactory.php │ ├── PersistentProxyObjectFactory.php │ ├── Proxy.php │ ├── ProxyGenerator.php │ ├── ProxyRepositoryDecorator.php │ ├── Relationship │ │ ├── ManyToOneRelationship.php │ │ ├── OneToManyRelationship.php │ │ ├── OneToOneRelationship.php │ │ └── RelationshipMetadata.php │ ├── RepositoryAssertions.php │ ├── RepositoryDecorator.php │ ├── ResetDatabase │ │ ├── BeforeEachTestResetter.php │ │ ├── BeforeFirstTestResetter.php │ │ └── ResetDatabaseManager.php │ └── functions.php ├── Story.php ├── StoryRegistry.php ├── Test │ ├── Factories.php │ ├── ResetDatabase.php │ └── UnitTestConfig.php ├── ZenstruckFoundryBundle.php ├── functions.php └── symfony_console.php └── utils └── psalm ├── FixProxyFactoryMethodsReturnType.php └── FoundryPlugin.php /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 | -------------------------------------------------------------------------------- /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/property-access": "^6.4|^7.0", 23 | "symfony/property-info": "^6.4|^7.0", 24 | "symfony/var-exporter": "^6.4.9|~7.0.9|^7.1.2", 25 | "zenstruck/assert": "^1.4" 26 | }, 27 | "require-dev": { 28 | "bamarni/composer-bin-plugin": "^1.8", 29 | "brianium/paratest": "^6|^7", 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", 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/persistence": "^2.0|^3.0|^4.0", 38 | "doctrine/orm": "^2.16|^3.0", 39 | "phpunit/phpunit": "^9.5.0 || ^10.0 || ^11.0 || ^12.0", 40 | "symfony/console": "^6.4|^7.0", 41 | "symfony/dotenv": "^6.4|^7.0", 42 | "symfony/framework-bundle": "^6.4|^7.0", 43 | "symfony/maker-bundle": "^1.55", 44 | "symfony/phpunit-bridge": "^6.4|^7.0", 45 | "symfony/runtime": "^6.4|^7.0", 46 | "symfony/translation-contracts": "^3.4", 47 | "symfony/uid": "^6.4|^7.0", 48 | "symfony/var-dumper": "^6.4|^7.0", 49 | "symfony/yaml": "^6.4|^7.0" 50 | }, 51 | "autoload": { 52 | "psr-4": { 53 | "Zenstruck\\Foundry\\": "src/", 54 | "Zenstruck\\Foundry\\Psalm\\": "utils/psalm" 55 | }, 56 | "files": [ 57 | "src/functions.php", 58 | "src/Persistence/functions.php", 59 | "src/symfony_console.php" 60 | ] 61 | }, 62 | "autoload-dev": { 63 | "psr-4": { 64 | "Zenstruck\\Foundry\\Tests\\": ["tests/"], 65 | "App\\": "tests/Fixture/Maker/tmp/src", 66 | "App\\Tests\\": "tests/Fixture/Maker/tmp/tests" 67 | }, 68 | "exclude-from-classmap": ["tests/Fixture/Maker/expected"] 69 | }, 70 | "config": { 71 | "preferred-install": "dist", 72 | "sort-packages": true, 73 | "allow-plugins": { 74 | "bamarni/composer-bin-plugin": true, 75 | "symfony/flex": true, 76 | "symfony/runtime": false 77 | } 78 | }, 79 | "conflict": { 80 | "doctrine/persistence": "<2.0", 81 | "symfony/framework-bundle": "<6.4" 82 | }, 83 | "extra": { 84 | "bamarni-bin": { 85 | "target-directory": "bin/tools", 86 | "bin-links": true, 87 | "forward-command": false 88 | }, 89 | "psalm": { 90 | "pluginClass": "Zenstruck\\Foundry\\Psalm\\FoundryPlugin" 91 | } 92 | }, 93 | "scripts": { 94 | "post-install-cmd": ["@composer bin phpstan install", "@composer bin phpbench install"] 95 | }, 96 | "minimum-stability": "dev", 97 | "prefer-stable": true 98 | } 99 | -------------------------------------------------------------------------------- /config/command_stubs.php: -------------------------------------------------------------------------------- 1 | services() 9 | ->set('.zenstruck_foundry.make_factory_command', StubCommand::class) 10 | ->args([ 11 | "To run \"make:factory\" you need the \"MakerBundle\" which is currently not installed.\n\nTry running \"composer require symfony/maker-bundle --dev\"." 12 | ]) 13 | ->tag('console.command', [ 14 | 'command' => '|make:factory', 15 | 'description' => 'Creates a Foundry object factory', 16 | ]) 17 | ->set('.zenstruck_foundry.make_story_command', StubCommand::class) 18 | ->args([ 19 | "To run \"make:story\" you need the \"MakerBundle\" which is currently not installed.\n\nTry running \"composer require symfony/maker-bundle --dev\"." 20 | ]) 21 | ->tag('console.command', [ 22 | 'command' => '|make:story', 23 | 'description' => 'Creates a Foundry story', 24 | ]) 25 | ; 26 | }; 27 | -------------------------------------------------------------------------------- /config/in_memory.php: -------------------------------------------------------------------------------- 1 | services() 10 | ->set('.zenstruck_foundry.in_memory.factory_registry', InMemoryFactoryRegistry::class) 11 | ->decorate('.zenstruck_foundry.factory_registry') 12 | ->arg('$decorated', service('.inner')); 13 | 14 | $container->services() 15 | ->set('.zenstruck_foundry.in_memory.repository_registry', InMemoryRepositoryRegistry::class) 16 | ->arg('$inMemoryRepositories', abstract_arg('inMemoryRepositories')) 17 | ; 18 | }; 19 | -------------------------------------------------------------------------------- /config/makers.php: -------------------------------------------------------------------------------- 1 | services() 21 | ->set('.zenstruck_foundry.maker.story', MakeStory::class) 22 | ->args([ 23 | service('.zenstruck_foundry.maker.namespace_guesser') 24 | ]) 25 | ->tag('maker.command') 26 | 27 | ->set('.zenstruck_foundry.maker.factory', MakeFactory::class) 28 | ->args([ 29 | service('kernel'), 30 | service('.zenstruck_foundry.maker.factory.generator'), 31 | service('.zenstruck_foundry.maker.factory.autoCompleter'), 32 | service('.zenstruck_foundry.maker.factory.candidate_classes_extractor'), 33 | ]) 34 | ->tag('maker.command') 35 | 36 | ->set('.zenstruck_foundry.maker.factory.orm_default_properties_guesser', DoctrineOrmVersionGuesser::isOrmV3() ? ORMDefaultPropertiesGuesser::class : LegacyORMDefaultPropertiesGuesser::class) 37 | ->args([ 38 | service('.zenstruck_foundry.persistence_manager')->nullOnInvalid(), 39 | service('.zenstruck_foundry.maker.factory.factory_class_map'), 40 | service('.zenstruck_foundry.maker.factory.generator'), 41 | ]) 42 | ->tag('foundry.make_factory.default_properties_guesser') 43 | 44 | ->set('.zenstruck_foundry.maker.factory.odm_default_properties_guesser', ODMDefaultPropertiesGuesser::class) 45 | ->args([ 46 | service('.zenstruck_foundry.persistence_manager')->nullOnInvalid(), 47 | service('.zenstruck_foundry.maker.factory.factory_class_map'), 48 | service('.zenstruck_foundry.maker.factory.generator'), 49 | ]) 50 | ->tag('foundry.make_factory.default_properties_guesser') 51 | 52 | ->set('.zenstruck_foundry.maker.factory.doctrine_scalar_fields_default_properties_guesser', DoctrineScalarFieldsDefaultPropertiesGuesser::class) 53 | ->args([ 54 | service('.zenstruck_foundry.persistence_manager')->nullOnInvalid(), 55 | service('.zenstruck_foundry.maker.factory.factory_class_map'), 56 | service('.zenstruck_foundry.maker.factory.generator'), 57 | ]) 58 | ->tag('foundry.make_factory.default_properties_guesser') 59 | 60 | ->set('.zenstruck_foundry.maker.factory.object_default_properties_guesser', ObjectDefaultPropertiesGuesser::class) 61 | ->args([ 62 | service('.zenstruck_foundry.maker.factory.factory_class_map'), 63 | service('.zenstruck_foundry.maker.factory.generator'), 64 | ]) 65 | ->tag('foundry.make_factory.default_properties_guesser') 66 | 67 | ->set('.zenstruck_foundry.maker.factory.factory_class_map', FactoryClassMap::class) 68 | ->args([ 69 | tagged_iterator('foundry.factory'), 70 | ]) 71 | 72 | ->set('.zenstruck_foundry.maker.factory.generator', FactoryGenerator::class) 73 | ->args([ 74 | service('.zenstruck_foundry.persistence_manager')->nullOnInvalid(), 75 | service('kernel'), 76 | tagged_iterator('foundry.make_factory.default_properties_guesser'), 77 | service('.zenstruck_foundry.maker.factory.factory_class_map'), 78 | service('.zenstruck_foundry.maker.namespace_guesser'), 79 | ]) 80 | 81 | ->set('.zenstruck_foundry.maker.factory.autoCompleter', NoPersistenceObjectsAutoCompleter::class) 82 | ->args([ 83 | param('kernel.project_dir') 84 | ]) 85 | 86 | ->set('.zenstruck_foundry.maker.factory.candidate_classes_extractor', FactoryCandidatesClassesExtractor::class) 87 | ->args([ 88 | service('.zenstruck_foundry.persistence_manager')->nullOnInvalid(), 89 | service('.zenstruck_foundry.maker.factory.factory_class_map'), 90 | ]) 91 | 92 | ->set('.zenstruck_foundry.maker.namespace_guesser', NamespaceGuesser::class) 93 | ->args([ 94 | service('.zenstruck_foundry.persistence_manager')->nullOnInvalid(), 95 | ]) 96 | ; 97 | }; 98 | -------------------------------------------------------------------------------- /config/mongo.php: -------------------------------------------------------------------------------- 1 | services() 11 | ->set('.zenstruck_foundry.persistence_strategy.mongo', MongoPersistenceStrategy::class) 12 | ->args([ 13 | service('doctrine_mongodb'), 14 | ]) 15 | ->tag('.foundry.persistence_strategy') 16 | 17 | ->set(MongoResetter::class, MongoSchemaResetter::class) 18 | ->args([ 19 | abstract_arg('managers'), 20 | ]) 21 | ->tag('.foundry.persistence.schema_resetter') 22 | ; 23 | }; 24 | -------------------------------------------------------------------------------- /config/orm.php: -------------------------------------------------------------------------------- 1 | services() 15 | ->set('.zenstruck_foundry.persistence_strategy.orm', DoctrineOrmVersionGuesser::isOrmV3() ? OrmV3PersistenceStrategy::class : OrmV2PersistenceStrategy::class) 16 | ->args([ 17 | service('doctrine'), 18 | ]) 19 | ->tag('.foundry.persistence_strategy') 20 | 21 | ->set('.zenstruck_foundry.persistence.database_resetter.orm.abstract', BaseOrmResetter::class) 22 | ->arg('$registry', service('doctrine')) 23 | ->arg('$managers', service('managers')) 24 | ->arg('$connections', service('connections')) 25 | ->abstract() 26 | 27 | ->set(OrmResetter::class, /* class to be defined thanks to the configuration */) 28 | ->parent('.zenstruck_foundry.persistence.database_resetter.orm.abstract') 29 | ->tag('.foundry.persistence.database_resetter') 30 | ->tag('.foundry.persistence.schema_resetter') 31 | ; 32 | 33 | if (\class_exists(StaticDriver::class)) { 34 | $container->services() 35 | ->set('.zenstruck_foundry.persistence.database_resetter.orm.dama', DamaDatabaseResetter::class) 36 | ->decorate(OrmResetter::class, priority: 10) 37 | ->args([ 38 | service('.inner'), 39 | ]) 40 | ; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /config/persistence.php: -------------------------------------------------------------------------------- 1 | services() 11 | ->set('.zenstruck_foundry.persistence_manager', PersistenceManager::class) 12 | ->args([ 13 | tagged_iterator('.foundry.persistence_strategy'), 14 | service('.zenstruck_foundry.persistence.reset_database_manager'), 15 | ]) 16 | ->set('.zenstruck_foundry.persistence.reset_database_manager', ResetDatabaseManager::class) 17 | ->args([ 18 | tagged_iterator('.foundry.persistence.database_resetter'), 19 | tagged_iterator('.foundry.persistence.schema_resetter'), 20 | ]) 21 | 22 | ->set('.zenstruck_foundry.story.load_story-command', LoadStoryCommand::class) 23 | ->arg('$databaseResetters', tagged_iterator('.foundry.persistence.database_resetter')) 24 | ->arg('$kernel', service('kernel')) 25 | ->tag('console.command', [ 26 | 'command' => 'foundry:load-stories', 27 | 'aliases' => ['foundry:load-fixtures', 'foundry:load-fixture', 'foundry:load-story'], 28 | 'description' => 'Load stories which are marked with #[AsFixture] attribute.', 29 | ]) 30 | ; 31 | }; 32 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | services() 13 | ->set('.zenstruck_foundry.faker', Faker\Generator::class) 14 | ->factory([Faker\Factory::class, 'create']) 15 | 16 | ->set('.zenstruck_foundry.factory_registry', FactoryRegistry::class) 17 | ->args([tagged_iterator('foundry.factory')]) 18 | 19 | ->set('.zenstruck_foundry.story_registry', StoryRegistry::class) 20 | ->args([ 21 | tagged_iterator('foundry.story'), 22 | abstract_arg('global_stories'), 23 | ]) 24 | 25 | ->set('.zenstruck_foundry.instantiator', Instantiator::class) 26 | ->factory([Instantiator::class, 'withConstructor']) 27 | 28 | ->set('.zenstruck_foundry.configuration', Configuration::class) 29 | ->args([ 30 | service('.zenstruck_foundry.factory_registry'), 31 | service('.zenstruck_foundry.faker'), 32 | service('.zenstruck_foundry.instantiator'), 33 | service('.zenstruck_foundry.story_registry'), 34 | service('.zenstruck_foundry.persistence_manager')->nullOnInvalid(), 35 | param('zenstruck_foundry.persistence.flush_once'), 36 | '%env(default:zenstruck_foundry.faker.seed:int:FOUNDRY_FAKER_SEED)%', 37 | service('.zenstruck_foundry.in_memory.repository_registry'), 38 | ]) 39 | ->public() 40 | ; 41 | }; 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /phpunit-deprecation-baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /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 | public static function class(): string 40 | { 41 | return getObjectShortName(); ?>::class; 42 | } 43 | 44 | shouldAddHints()): ?> /** 45 | * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories 46 | * 47 | * @todo add your default values here 48 | */ 49 | protected function defaults(): arrayshouldAddHints()): ?>|callable 50 | { 51 | return [ 52 | getDefaultProperties() as $propertyName => $value) { 54 | echo " '{$propertyName}' => {$value}\n"; 55 | } 56 | ?> 57 | ]; 58 | } 59 | 60 | shouldAddHints()): ?> /** 61 | * @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization 62 | */ 63 | protected function initialize(): static 64 | { 65 | return $this 66 | // ->afterInstantiate(function(getObjectShortName(); ?> $getObjectShortName()); ?>): void {}) 67 | ; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /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/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 = << 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/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/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/Command/LoadStoryCommand.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 | final class LoadStoryCommand extends Command 31 | { 32 | public function __construct( 33 | /** @var array> */ 34 | private readonly array $stories, 35 | /** @var array>> */ 36 | private readonly array $groupedStories, 37 | /** @var iterable */ 38 | private iterable $databaseResetters, 39 | private KernelInterface $kernel, 40 | ) { 41 | parent::__construct(); 42 | } 43 | 44 | protected function configure(): void 45 | { 46 | $this 47 | ->addArgument('name', InputArgument::OPTIONAL, 'The name of the story to load.') 48 | ->addOption('append', 'a', InputOption::VALUE_NONE, 'Skip resetting database and append data to the existing database.') 49 | ; 50 | } 51 | 52 | protected function execute(InputInterface $input, OutputInterface $output): int 53 | { 54 | if (0 === \count($this->stories)) { 55 | throw new LogicException('No story as fixture available: add attribute #[AsFixture] to your story classes before running this command.'); 56 | } 57 | 58 | $io = new SymfonyStyle($input, $output); 59 | 60 | if (!$input->getOption('append')) { 61 | $this->resetDatabase(); 62 | } 63 | 64 | $stories = []; 65 | 66 | if (null === ($name = $input->getArgument('name'))) { 67 | if (1 === \count($this->stories)) { 68 | $name = \array_keys($this->stories)[0]; 69 | } else { 70 | $storyNames = \array_keys($this->stories); 71 | if (\count($this->groupedStories) > 0) { 72 | $storyNames[] = '(choose a group of stories...)'; 73 | } 74 | $name = $io->choice('Choose a story to load:', $storyNames); 75 | } 76 | 77 | if (!isset($this->stories[$name])) { 78 | $groupsNames = \array_keys($this->groupedStories); 79 | $name = $io->choice('Choose a group of stories:', $groupsNames); 80 | } 81 | } 82 | 83 | if (isset($this->stories[$name])) { 84 | $io->comment("Loading story with name \"{$name}\"..."); 85 | $stories = [$name => $this->stories[$name]]; 86 | } 87 | 88 | if (isset($this->groupedStories[$name])) { 89 | $io->comment("Loading stories group \"{$name}\"..."); 90 | $stories = $this->groupedStories[$name]; 91 | } 92 | 93 | if (!$stories) { 94 | throw new InvalidArgumentException("Story with name \"{$name}\" does not exist."); 95 | } 96 | 97 | foreach ($stories as $name => $storyClass) { 98 | $storyClass::load(); 99 | 100 | if ($io->isVerbose()) { 101 | $io->info("Story \"{$storyClass}\" loaded (name: {$name})."); 102 | } 103 | } 104 | 105 | $io->success('Stories successfully loaded!'); 106 | 107 | return self::SUCCESS; 108 | } 109 | 110 | private function resetDatabase(): void 111 | { 112 | // it is very not likely that we need dama when running this command 113 | if (\class_exists(StaticDriver::class) && StaticDriver::isKeepStaticConnections()) { 114 | StaticDriver::setKeepStaticConnections(false); 115 | } 116 | 117 | foreach ($this->databaseResetters as $databaseResetter) { 118 | $databaseResetter->resetBeforeFirstTest($this->kernel); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /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 | protected function execute(InputInterface $input, OutputInterface $output): int 36 | { 37 | throw new \RuntimeException($this->message); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Configuration.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\Exception\FactoriesTraitNotUsed; 16 | use Zenstruck\Foundry\Exception\FoundryNotBooted; 17 | use Zenstruck\Foundry\Exception\PersistenceDisabled; 18 | use Zenstruck\Foundry\Exception\PersistenceNotAvailable; 19 | use Zenstruck\Foundry\InMemory\CannotEnableInMemory; 20 | use Zenstruck\Foundry\InMemory\InMemoryRepositoryRegistry; 21 | use Zenstruck\Foundry\Persistence\PersistenceManager; 22 | 23 | /** 24 | * @author Kevin Bond 25 | * 26 | * @internal 27 | * 28 | * @phpstan-import-type InstantiatorCallable from ObjectFactory 29 | */ 30 | final class Configuration 31 | { 32 | /** 33 | * @readonly 34 | * 35 | * @phpstan-var InstantiatorCallable 36 | */ 37 | public $instantiator; 38 | 39 | /** 40 | * This property is only filled if the PHPUnit extension is used! 41 | */ 42 | private bool $bootedForDataProvider = false; 43 | 44 | /** @var \Closure():self|self|null */ 45 | private static \Closure|self|null $instance = null; 46 | 47 | private static ?int $fakerSeed = null; 48 | 49 | private bool $inMemory = false; 50 | 51 | /** 52 | * @phpstan-param InstantiatorCallable $instantiator 53 | */ 54 | public function __construct( 55 | public readonly FactoryRegistryInterface $factories, 56 | public readonly Faker\Generator $faker, 57 | callable $instantiator, 58 | public readonly StoryRegistry $stories, 59 | private readonly ?PersistenceManager $persistence = null, 60 | public readonly bool $flushOnce = false, 61 | ?int $forcedFakerSeed = null, 62 | public readonly ?InMemoryRepositoryRegistry $inMemoryRepositoryRegistry = null, 63 | ) { 64 | if (null === self::$instance) { 65 | $this->faker->seed(self::fakerSeed($forcedFakerSeed)); 66 | } 67 | 68 | $this->instantiator = $instantiator; 69 | } 70 | 71 | public static function fakerSeed(?int $forcedFakerSeed = null): int 72 | { 73 | return self::$fakerSeed ??= ($forcedFakerSeed ?? \random_int(0, 1000000)); 74 | } 75 | 76 | public static function resetFakerSeed(): void 77 | { 78 | self::$fakerSeed = null; 79 | } 80 | 81 | /** 82 | * @throws PersistenceNotAvailable 83 | */ 84 | public function persistence(): PersistenceManager 85 | { 86 | return $this->persistence ?? throw new PersistenceNotAvailable('No persistence managers configured. Note: persistence cannot be used in unit tests.'); 87 | } 88 | 89 | public function isPersistenceAvailable(): bool 90 | { 91 | return (bool) $this->persistence; 92 | } 93 | 94 | public function isPersistenceEnabled(): bool 95 | { 96 | return $this->isPersistenceAvailable() && $this->persistence()->isEnabled(); 97 | } 98 | 99 | public function assertPersistenceEnabled(): void 100 | { 101 | if (!$this->isPersistenceEnabled()) { 102 | throw new PersistenceDisabled('Cannot get repository when persist is disabled (if in a unit test, you probably should not try to get the repository).'); 103 | } 104 | } 105 | 106 | public function inADataProvider(): bool 107 | { 108 | return $this->bootedForDataProvider; 109 | } 110 | 111 | public static function instance(): self 112 | { 113 | if (!self::$instance) { 114 | throw new FoundryNotBooted(); 115 | } 116 | 117 | FactoriesTraitNotUsed::throwIfComingFromKernelTestCaseWithoutFactoriesTrait(); 118 | 119 | return \is_callable(self::$instance) ? (self::$instance)() : self::$instance; 120 | } 121 | 122 | public static function isBooted(): bool 123 | { 124 | return null !== self::$instance; 125 | } 126 | 127 | /** @param \Closure():self|self $configuration */ 128 | public static function boot(\Closure|self $configuration): void 129 | { 130 | self::$instance = $configuration; 131 | } 132 | 133 | /** @param \Closure():self|self $configuration */ 134 | public static function bootForDataProvider(\Closure|self $configuration): void 135 | { 136 | self::$instance = \is_callable($configuration) ? ($configuration)() : $configuration; 137 | self::$instance->bootedForDataProvider = true; 138 | } 139 | 140 | public static function shutdown(): void 141 | { 142 | StoryRegistry::reset(); 143 | self::$instance = null; 144 | } 145 | 146 | /** 147 | * @throws CannotEnableInMemory 148 | */ 149 | public function enableInMemory(): void 150 | { 151 | if (null === $this->inMemoryRepositoryRegistry) { 152 | throw CannotEnableInMemory::noInMemoryRepositoryRegistry(); 153 | } 154 | 155 | $this->inMemory = true; 156 | } 157 | 158 | /** 159 | * @phpstan-assert-if-true InMemoryRepositoryRegistry $this->inMemoryRepositoryRegistry 160 | */ 161 | public function isInMemoryEnabled(): bool 162 | { 163 | return $this->inMemory; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /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.story.load_story-command')) { 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.story.load_story-command') 62 | ->setArgument('$stories', $fixtureStories) 63 | ->setArgument('$groupedStories', $groupedFixtureStories); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /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/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 | foreach ($backTrace as $trace) { 39 | if ( 40 | '->' === ($trace['type'] ?? null) 41 | && isset($trace['class']) 42 | && KernelTestCase::class !== $trace['class'] 43 | && \is_a($trace['class'], KernelTestCase::class, allow_string: true) 44 | ) { 45 | self::throwIfClassDoesNotHaveFactoriesTrait($trace['class']); 46 | } 47 | } 48 | } 49 | 50 | /** 51 | * @param class-string $class 52 | */ 53 | public static function throwIfClassDoesNotHaveFactoriesTrait(string $class): void 54 | { 55 | if (!(new \ReflectionClass($class))->hasMethod('_bootFoundry')) { 56 | // throw new self($class); 57 | trigger_deprecation( 58 | 'zenstruck/foundry', 59 | '2.4', 60 | 'In order to use Foundry correctly, you must use the trait "%s" in your "%s" tests. This will throw an exception in 3.0.', 61 | Factories::class, 62 | $class 63 | ); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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 | 19 | /** 20 | * @internal 21 | */ 22 | final class DoctrineScalarFieldsDefaultPropertiesGuesser extends AbstractDoctrineDefaultPropertiesGuesser 23 | { 24 | private const DEFAULTS = [ 25 | 'ARRAY' => '[],', 26 | 'ASCII_STRING' => 'self::faker()->text({length}),', 27 | 'BIGINT' => 'self::faker()->randomNumber(),', 28 | 'BLOB' => 'self::faker()->text(),', 29 | 'BOOLEAN' => 'self::faker()->boolean(),', 30 | 'DATE' => 'self::faker()->dateTime(),', 31 | 'DATE_MUTABLE' => 'self::faker()->dateTime(),', 32 | 'DATE_IMMUTABLE' => '\DateTimeImmutable::createFromMutable(self::faker()->dateTime()),', 33 | 'DATETIME' => 'self::faker()->dateTime(),', 34 | 'DATETIME_MUTABLE' => 'self::faker()->dateTime(),', 35 | 'DATETIME_IMMUTABLE' => '\DateTimeImmutable::createFromMutable(self::faker()->dateTime()),', 36 | 'DATETIMETZ_MUTABLE' => 'self::faker()->dateTime(),', 37 | 'DATETIMETZ_IMMUTABLE' => '\DateTimeImmutable::createFromMutable(self::faker()->dateTime()),', 38 | 'DECIMAL' => 'self::faker()->randomFloat(),', 39 | 'FLOAT' => 'self::faker()->randomFloat(),', 40 | 'INTEGER' => 'self::faker()->randomNumber(),', 41 | 'INT' => 'self::faker()->randomNumber(),', 42 | 'JSON' => '[],', 43 | 'JSON_ARRAY' => '[],', 44 | 'SIMPLE_ARRAY' => '[],', 45 | 'SMALLINT' => 'self::faker()->numberBetween(1, 32767),', 46 | 'STRING' => 'self::faker()->text({length}),', 47 | 'TEXT' => 'self::faker()->text({length}),', 48 | 'TIME_MUTABLE' => 'self::faker()->datetime(),', 49 | 'TIME_IMMUTABLE' => '\DateTimeImmutable::createFromMutable(self::faker()->datetime()),', 50 | ]; 51 | 52 | public function __invoke(SymfonyStyle $io, MakeFactoryData $makeFactoryData, MakeFactoryQuery $makeFactoryQuery): void 53 | { 54 | /** @var ODMClassMetadata|ORMClassMetadata $metadata */ 55 | $metadata = $this->getClassMetadata($makeFactoryData); 56 | 57 | $ids = $metadata->getIdentifierFieldNames(); 58 | 59 | foreach ($metadata->fieldMappings as $property) { 60 | if (\is_array($property) && ($property['embedded'] ?? false)) { 61 | // skip ODM embedded 62 | continue; 63 | } 64 | 65 | $fieldName = $this->extractFieldMappingData($property, 'fieldName'); 66 | 67 | if (\str_contains($fieldName, '.')) { 68 | // this is a "subfield" of an ORM embeddable field. 69 | continue; 70 | } 71 | 72 | // ignore identifiers and nullable fields 73 | if ((!$makeFactoryQuery->isAllFields() && $this->extractFieldMappingData($property, 'nullable', false)) || \in_array($fieldName, $ids, true)) { 74 | continue; 75 | } 76 | 77 | $type = \mb_strtoupper($this->extractFieldMappingData($property, 'type')); 78 | if ($this->extractFieldMappingData($property, 'enumType')) { 79 | $makeFactoryData->addEnumDefaultProperty($fieldName, $this->extractFieldMappingData($property, 'enumType')); 80 | 81 | continue; 82 | } 83 | 84 | $value = "null, // TODO add {$type} type manually"; 85 | $length = $this->extractFieldMappingData($property, 'length', ''); 86 | 87 | if (\array_key_exists($type, self::DEFAULTS)) { 88 | $value = self::DEFAULTS[$type]; 89 | } 90 | 91 | $makeFactoryData->addDefaultProperty($fieldName, \str_replace('{length}', (string) $length, $value)); 92 | } 93 | } 94 | 95 | public function supports(MakeFactoryData $makeFactoryData): bool 96 | { 97 | return $makeFactoryData->isPersisted(); 98 | } 99 | 100 | // handles both ORM 3 & 4 101 | private function extractFieldMappingData(FieldMapping|array $fieldMapping, string $field, mixed $default = null): mixed 102 | { 103 | if ($fieldMapping instanceof FieldMapping) { 104 | return $fieldMapping->{$field}; 105 | } else { 106 | return $fieldMapping[$field] ?? $default; 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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(), 'randomSet(int $number, array $attributes = [])', returnsCollection: true); 49 | 50 | if (null !== $makeFactoryData->getRepositoryReflectionClass()) { 51 | $methods[] = new self($makeFactoryData->getObjectShortName(), 'repository()', returnsCollection: false, repository: $makeFactoryData->getRepositoryReflectionClass()); 52 | } 53 | } 54 | 55 | return $methods; 56 | } 57 | 58 | public function toString(?string $staticAnalysisTool = null): string 59 | { 60 | $annotation = $staticAnalysisTool ? "{$staticAnalysisTool}-method" : 'method'; 61 | $static = $this->isStatic ? 'static' : ' '; 62 | 63 | if ($this->repository) { 64 | $returnType = match ((bool) $staticAnalysisTool) { 65 | false => "{$this->repository->getShortName()}|ProxyRepositoryDecorator", 66 | true => \sprintf( 67 | "ProxyRepositoryDecorator<{$this->objectName}, %s>", 68 | \is_a($this->repository->getName(), DocumentRepository::class, allow_string: true) 69 | ? "DocumentRepository<{$this->objectName}>" 70 | : "EntityRepository<{$this->objectName}>" 71 | ), 72 | }; 73 | } else { 74 | $returnType = match ([$this->returnsCollection, (bool) $staticAnalysisTool]) { 75 | [true, true] => "list<{$this->objectName}&Proxy<{$this->objectName}>>", 76 | [true, false] => "{$this->objectName}[]|Proxy[]", 77 | [false, true] => "{$this->objectName}&Proxy<{$this->objectName}>", 78 | [false, false] => "{$this->objectName}|Proxy", 79 | }; 80 | } 81 | 82 | return " * @{$annotation} {$static} {$returnType} {$this->prototype}"; 83 | } 84 | 85 | public function sortValue(): string 86 | { 87 | return \sprintf( 88 | "returnsCollection:%s, prototype:{$this->prototype}", 89 | $this->returnsCollection ? '1' : '0', 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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)); 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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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; 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/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/ORM/ResetDatabase/MigrateDatabaseResetter.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 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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | final class RefreshObjectFailed extends \RuntimeException 17 | { 18 | private function __construct(string $message, private bool $objectWasDeleted = false) 19 | { 20 | parent::__construct($message); 21 | } 22 | 23 | public static function objectNoLongExists(): static 24 | { 25 | return new self('object no longer exists...', objectWasDeleted: true); 26 | } 27 | 28 | /** 29 | * @param class-string $objectClass 30 | */ 31 | public static function objectHasUnsavedChanges(string $objectClass): static 32 | { 33 | return new self( 34 | "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)." 35 | ); 36 | } 37 | 38 | public function objectWasDeleted(): bool 39 | { 40 | return $this->objectWasDeleted; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Persistence/IsProxy.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 Symfony\Component\VarExporter\LazyProxyTrait; 15 | use Zenstruck\Assert; 16 | use Zenstruck\Foundry\Configuration; 17 | use Zenstruck\Foundry\Exception\PersistenceNotAvailable; 18 | use Zenstruck\Foundry\Object\Hydrator; 19 | use Zenstruck\Foundry\Persistence\Exception\RefreshObjectFailed; 20 | 21 | /** 22 | * @author Kevin Bond 23 | * 24 | * @internal 25 | * 26 | * @mixin LazyProxyTrait 27 | */ 28 | trait IsProxy // @phpstan-ignore trait.unused 29 | { 30 | private static array $_autoRefresh = []; 31 | 32 | public function _enableAutoRefresh(): static 33 | { 34 | $this->_setAutoRefresh(true); 35 | 36 | return $this; 37 | } 38 | 39 | public function _disableAutoRefresh(): static 40 | { 41 | $this->_setAutoRefresh(false); 42 | 43 | return $this; 44 | } 45 | 46 | public function _withoutAutoRefresh(callable $callback): static 47 | { 48 | $original = $this->_getAutoRefresh(); 49 | $this->_setAutoRefresh(false); 50 | 51 | $callback($this); 52 | 53 | $this->_setAutoRefresh($original); 54 | 55 | return $this; 56 | } 57 | 58 | public function _save(): static 59 | { 60 | Configuration::instance()->persistence()->save($this->initializeLazyObject()); 61 | 62 | return $this; 63 | } 64 | 65 | public function _refresh(): static 66 | { 67 | $this->initializeLazyObject(); 68 | $object = $this->lazyObjectState->realInstance; 69 | 70 | Configuration::instance()->persistence()->refresh($object); 71 | 72 | $this->lazyObjectState->realInstance = $object; 73 | 74 | return $this; 75 | } 76 | 77 | public function _delete(): static 78 | { 79 | Configuration::instance()->persistence()->delete($this->initializeLazyObject()); 80 | 81 | return $this; 82 | } 83 | 84 | public function _get(string $property): mixed 85 | { 86 | $this->_autoRefresh(); 87 | 88 | return Hydrator::get($this->initializeLazyObject(), $property); 89 | } 90 | 91 | public function _set(string $property, mixed $value): static 92 | { 93 | $this->_autoRefresh(); 94 | 95 | Hydrator::set($this->initializeLazyObject(), $property, $value); 96 | 97 | return $this; 98 | } 99 | 100 | public function _real(bool $withAutoRefresh = true): object 101 | { 102 | if ($withAutoRefresh) { 103 | try { 104 | // we don't want the auto-refresh mechanism to break "real" object retrieval 105 | $this->_autoRefresh(); 106 | } catch (\Throwable) { 107 | } 108 | } 109 | 110 | return $this->initializeLazyObject(); 111 | } 112 | 113 | public function _repository(): ProxyRepositoryDecorator 114 | { 115 | return new ProxyRepositoryDecorator(parent::class, Configuration::instance()->isInMemoryEnabled()); 116 | } 117 | 118 | public function _assertPersisted(string $message = '{entity} is not persisted.'): static 119 | { 120 | Assert::that($this->isPersisted())->isTrue($message, ['entity' => parent::class]); 121 | 122 | return $this; 123 | } 124 | 125 | public function _assertNotPersisted(string $message = '{entity} is persisted but it should not be.'): static 126 | { 127 | Assert::that($this->isPersisted())->isFalse($message, ['entity' => parent::class]); 128 | 129 | return $this; 130 | } 131 | 132 | public function _initializeLazyObject(): void 133 | { 134 | $this->initializeLazyObject(); 135 | } 136 | 137 | private function isPersisted(): bool 138 | { 139 | $this->initializeLazyObject(); 140 | $object = $this->lazyObjectState->realInstance; 141 | 142 | return Configuration::instance()->persistence()->isPersisted($object); 143 | } 144 | 145 | private function _autoRefresh(): void 146 | { 147 | if (!$this->_getAutoRefresh()) { 148 | return; 149 | } 150 | 151 | try { 152 | // we don't want that "transparent" calls to _refresh() to trigger a PersistenceNotAvailable exception 153 | // or a RefreshObjectFailed exception when the object was deleted 154 | $this->_refresh(); 155 | } catch (PersistenceNotAvailable|RefreshObjectFailed $e) { 156 | if ($e instanceof RefreshObjectFailed && false === $e->objectWasDeleted()) { 157 | throw $e; 158 | } 159 | } 160 | } 161 | 162 | private function _getAutoRefresh(): bool 163 | { 164 | $real = $this->initializeLazyObject(); 165 | 166 | static::$_autoRefresh[\spl_object_id($real)] ??= true; 167 | 168 | return static::$_autoRefresh[\spl_object_id($real)]; 169 | } 170 | 171 | private function _setAutoRefresh(bool $autoRefresh): void 172 | { 173 | $real = $this->initializeLazyObject(); 174 | 175 | static::$_autoRefresh[\spl_object_id($real)] = $autoRefresh; 176 | } 177 | 178 | // used in ProxyGenerator 179 | private function unproxyArgs(array $args): array 180 | { 181 | return \array_map(unproxy(...), $args); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /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/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 list 84 | */ 85 | abstract public function managedNamespaces(): array; 86 | 87 | /** 88 | * @param class-string $owner 89 | * 90 | * @return array|null 91 | */ 92 | abstract public function embeddablePropertiesFor(object $object, string $owner): ?array; 93 | 94 | abstract public function isEmbeddable(object $object): bool; 95 | 96 | abstract public function isScheduledForInsert(object $object): bool; 97 | } 98 | -------------------------------------------------------------------------------- /src/Persistence/PersistentProxyObjectFactory.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\Foundry\Configuration; 16 | 17 | /** 18 | * @author Kevin Bond 19 | * 20 | * @template T of object 21 | * @extends PersistentObjectFactory> 22 | */ 23 | abstract class PersistentProxyObjectFactory extends PersistentObjectFactory 24 | { 25 | /** 26 | * @return class-string 27 | */ 28 | abstract public static function class(): string; 29 | 30 | /** 31 | * @return T|Proxy 32 | * @phpstan-return T&Proxy 33 | */ 34 | final public function create(callable|array $attributes = []): object 35 | { 36 | $configuration = Configuration::instance(); 37 | if ($configuration->inADataProvider()) { 38 | return ProxyGenerator::wrapFactory($this, $attributes); 39 | } 40 | 41 | return proxy(parent::create($attributes)); // @phpstan-ignore function.unresolvableReturnType 42 | } 43 | 44 | /** 45 | * @return T|Proxy 46 | * @phpstan-return T&Proxy 47 | */ 48 | final public static function createOne(array|callable $attributes = []): mixed 49 | { 50 | return proxy(parent::createOne($attributes)); // @phpstan-ignore function.unresolvableReturnType 51 | } 52 | 53 | /** 54 | * @return T|Proxy 55 | * @phpstan-return T&Proxy 56 | */ 57 | final public static function find(mixed $criteriaOrId): object 58 | { 59 | return proxy(parent::find($criteriaOrId)); // @phpstan-ignore function.unresolvableReturnType 60 | } 61 | 62 | /** 63 | * @return T|Proxy 64 | * @phpstan-return T&Proxy 65 | */ 66 | final public static function findOrCreate(array $criteria): object 67 | { 68 | return proxy(parent::findOrCreate($criteria)); // @phpstan-ignore function.unresolvableReturnType 69 | } 70 | 71 | /** 72 | * @return T|Proxy 73 | * @phpstan-return T&Proxy 74 | */ 75 | final public static function randomOrCreate(array $criteria = []): object 76 | { 77 | return proxy(parent::randomOrCreate($criteria)); // @phpstan-ignore function.unresolvableReturnType 78 | } 79 | 80 | /** 81 | * @return list> 82 | */ 83 | final public static function randomSet(int $count, array $criteria = []): array 84 | { 85 | return \array_map(proxy(...), parent::randomSet($count, $criteria)); 86 | } 87 | 88 | /** 89 | * @return list> 90 | */ 91 | final public static function randomRange(int $min, int $max, array $criteria = []): array 92 | { 93 | return \array_map(proxy(...), parent::randomRange($min, $max, $criteria)); 94 | } 95 | 96 | /** 97 | * @return list> 98 | */ 99 | final public static function findBy(array $criteria): array 100 | { 101 | return \array_map(proxy(...), parent::findBy($criteria)); 102 | } 103 | 104 | /** 105 | * @return T|Proxy 106 | * @phpstan-return T&Proxy 107 | */ 108 | final public static function random(array $criteria = []): object 109 | { 110 | return proxy(parent::random($criteria)); // @phpstan-ignore function.unresolvableReturnType 111 | } 112 | 113 | /** 114 | * @return T|Proxy 115 | * @phpstan-return T&Proxy 116 | */ 117 | final public static function first(string $sortBy = 'id'): object 118 | { 119 | return proxy(parent::first($sortBy)); // @phpstan-ignore function.unresolvableReturnType 120 | } 121 | 122 | /** 123 | * @return T|Proxy 124 | * @phpstan-return T&Proxy 125 | */ 126 | final public static function last(string $sortBy = 'id'): object 127 | { 128 | return proxy(parent::last($sortBy)); // @phpstan-ignore function.unresolvableReturnType 129 | } 130 | 131 | /** 132 | * @return list> 133 | */ 134 | final public static function all(): array 135 | { 136 | return \array_map(proxy(...), parent::all()); 137 | } 138 | 139 | /** 140 | * @return ProxyRepositoryDecorator> 141 | */ 142 | final public static function repository(): ObjectRepository 143 | { 144 | Configuration::instance()->assertPersistenceEnabled(); 145 | 146 | return new ProxyRepositoryDecorator(static::class(), Configuration::instance()->isInMemoryEnabled()); // @phpstan-ignore argument.type, return.type 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /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 | * @psalm-return T&Proxy 26 | * @phpstan-return static 27 | */ 28 | public function _enableAutoRefresh(): static; 29 | 30 | /** 31 | * @psalm-return T&Proxy 32 | * @phpstan-return static 33 | */ 34 | public function _disableAutoRefresh(): static; 35 | 36 | /** 37 | * @param callable(static):void $callback 38 | * @psalm-return T&Proxy 39 | * @phpstan-return static 40 | */ 41 | public function _withoutAutoRefresh(callable $callback): static; 42 | 43 | /** 44 | * @psalm-return T&Proxy 45 | * @phpstan-return static 46 | */ 47 | public function _save(): static; 48 | 49 | /** 50 | * @psalm-return T&Proxy 51 | * @phpstan-return static 52 | */ 53 | public function _refresh(): static; 54 | 55 | /** 56 | * @psalm-return T&Proxy 57 | * @phpstan-return static 58 | */ 59 | public function _delete(): static; 60 | 61 | public function _get(string $property): mixed; 62 | 63 | /** 64 | * @psalm-return T&Proxy 65 | * @phpstan-return static 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 | * @psalm-return T&Proxy 76 | * @phpstan-return static 77 | */ 78 | public function _assertPersisted(string $message = '{entity} is not persisted.'): static; 79 | 80 | /** 81 | * @psalm-return T&Proxy 82 | * @phpstan-return static 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/Persistence/ProxyRepositoryDecorator.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 | * @template I of ObjectRepository 21 | * @extends RepositoryDecorator, I> 22 | */ 23 | final class ProxyRepositoryDecorator extends RepositoryDecorator 24 | { 25 | /** 26 | * @return T|Proxy|null 27 | * @psalm-return (T&Proxy)|null 28 | */ 29 | public function first(string $sortBy = 'id'): ?object 30 | { 31 | return $this->proxyNullableObject(parent::first($sortBy)); 32 | } 33 | 34 | /** 35 | * @return T|Proxy 36 | * @psalm-return T&Proxy 37 | */ 38 | public function firstOrFail(string $sortBy = 'id'): object 39 | { 40 | return proxy(parent::firstOrFail($sortBy)); 41 | } 42 | 43 | /** 44 | * @return T|Proxy|null 45 | * @psalm-return (T&Proxy)|null 46 | */ 47 | public function last(string $sortBy = 'id'): ?object 48 | { 49 | return $this->proxyNullableObject(parent::last($sortBy)); 50 | } 51 | 52 | /** 53 | * @return T|Proxy 54 | * @psalm-return T&Proxy 55 | */ 56 | public function lastOrFail(string $sortBy = 'id'): object 57 | { 58 | return proxy(parent::lastOrFail($sortBy)); 59 | } 60 | 61 | /** 62 | * @return T|Proxy|null 63 | * @psalm-return (T&Proxy)|null 64 | */ 65 | public function find($id): ?object 66 | { 67 | return $this->proxyNullableObject(parent::find($id)); 68 | } 69 | 70 | /** 71 | * @return T|Proxy 72 | * @psalm-return T&Proxy 73 | */ 74 | public function findOrFail(mixed $id): object 75 | { 76 | return proxy(parent::findOrFail($id)); 77 | } 78 | 79 | /** 80 | * @phpstan-return list> 81 | * @psalm-return list> 82 | */ 83 | public function findAll(): array 84 | { 85 | return $this->proxyArray(parent::findAll()); 86 | } 87 | 88 | /** 89 | * @psalm-return list> 90 | */ 91 | public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array 92 | { 93 | return $this->proxyArray(parent::findBy($criteria, $orderBy, $limit, $offset)); 94 | } 95 | 96 | /** 97 | * @return T|Proxy|null 98 | * @psalm-return (T&Proxy)|null 99 | */ 100 | public function findOneBy(array $criteria): ?object 101 | { 102 | return $this->proxyNullableObject(parent::findOneBy($criteria)); 103 | } 104 | 105 | /** 106 | * @return T|Proxy|null 107 | * @psalm-return T&Proxy 108 | */ 109 | public function random(array $criteria = []): object 110 | { 111 | return proxy(parent::random($criteria)); 112 | } 113 | 114 | /** 115 | * @psalm-return list> 116 | */ 117 | public function randomSet(int $count, array $criteria = []): array 118 | { 119 | return $this->proxyArray( 120 | parent::randomSet($count, $criteria) 121 | ); 122 | } 123 | 124 | /** 125 | * @psalm-return list> 126 | */ 127 | public function randomRange(int $min, int $max, array $criteria = []): array 128 | { 129 | return $this->proxyArray( 130 | parent::randomRange($min, $max, $criteria) 131 | ); 132 | } 133 | 134 | public function getIterator(): \Traversable 135 | { 136 | foreach (parent::getIterator() as $item) { 137 | yield proxy($item); 138 | } 139 | } 140 | 141 | public function count(array $criteria = []): int 142 | { 143 | return parent::count($criteria); 144 | } 145 | 146 | public function getClassName(): string 147 | { 148 | return parent::getClassName(); 149 | } 150 | 151 | /** 152 | * @param list $objects 153 | * @return list> 154 | */ 155 | private function proxyArray(array $objects): array 156 | { 157 | return \array_map( 158 | static fn(object $object) => proxy($object), 159 | $objects 160 | ); 161 | } 162 | 163 | /** 164 | * @param T|null $object 165 | * @return (T&Proxy)|null 166 | */ 167 | private function proxyNullableObject(?object $object): ?object 168 | { 169 | if (null === $object) { 170 | return null; 171 | } 172 | 173 | return proxy($object); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/Persistence/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\Persistence; 13 | 14 | use Doctrine\Persistence\ObjectRepository; 15 | use Zenstruck\Foundry\AnonymousFactoryGenerator; 16 | use Zenstruck\Foundry\Configuration; 17 | 18 | /** 19 | * @template T of object 20 | * 21 | * @param class-string $class 22 | * 23 | * @return RepositoryDecorator> 24 | */ 25 | function repository(string $class): RepositoryDecorator 26 | { 27 | return new RepositoryDecorator($class, Configuration::instance()->isInMemoryEnabled()); // @phpstan-ignore return.type 28 | } 29 | 30 | /** 31 | * @template T of object 32 | * 33 | * @param class-string $class 34 | * 35 | * @return ProxyRepositoryDecorator> 36 | */ 37 | function proxy_repository(string $class): ProxyRepositoryDecorator 38 | { 39 | return new ProxyRepositoryDecorator($class, Configuration::instance()->isInMemoryEnabled()); // @phpstan-ignore return.type, argument.type 40 | } 41 | 42 | /** 43 | * Create an anonymous "persistent" factory for the given class. 44 | * 45 | * @template T of object 46 | * 47 | * @param class-string $class 48 | * @param array|callable(int):array $attributes 49 | * 50 | * @return PersistentObjectFactory 51 | */ 52 | function persistent_factory(string $class, array|callable $attributes = []): PersistentObjectFactory 53 | { 54 | return AnonymousFactoryGenerator::create($class, PersistentObjectFactory::class)::new($attributes); 55 | } 56 | 57 | /** 58 | * Create an anonymous "persistent with proxy" factory for the given class. 59 | * 60 | * @template T of object 61 | * 62 | * @param class-string $class 63 | * @param array|callable(int):array $attributes 64 | * 65 | * @return PersistentProxyObjectFactory 66 | */ 67 | function proxy_factory(string $class, array|callable $attributes = []): PersistentProxyObjectFactory 68 | { 69 | return AnonymousFactoryGenerator::create($class, PersistentProxyObjectFactory::class)::new($attributes); 70 | } 71 | 72 | /** 73 | * Instantiate and "persist" the given class. 74 | * 75 | * @template T of object 76 | * 77 | * @param class-string $class 78 | * @param array|callable(int):array $attributes 79 | * 80 | * @return T 81 | */ 82 | function persist(string $class, array|callable $attributes = []): object 83 | { 84 | return persistent_factory($class, $attributes)->andPersist()->create(); 85 | } 86 | 87 | /** 88 | * Create an auto-refreshable proxy for the object. 89 | * 90 | * @template T of object 91 | * 92 | * @param T $object 93 | * 94 | * @return T&Proxy 95 | */ 96 | function proxy(object $object): object 97 | { 98 | return ProxyGenerator::wrap($object); 99 | } 100 | 101 | /** 102 | * Recursively unwrap all proxies. 103 | * 104 | * @template T 105 | * 106 | * @param T $what 107 | * 108 | * @return T 109 | */ 110 | function unproxy(mixed $what, bool $withAutoRefresh = true): mixed 111 | { 112 | return ProxyGenerator::unwrap($what, $withAutoRefresh); 113 | } 114 | 115 | /** 116 | * @template T of object 117 | * 118 | * @param T $object 119 | * 120 | * @return T 121 | */ 122 | function save(object $object): object 123 | { 124 | return Configuration::instance()->persistence()->save($object); 125 | } 126 | 127 | /** 128 | * @template T of object 129 | * 130 | * @param T $object 131 | * 132 | * @return T 133 | */ 134 | function refresh(object &$object): object 135 | { 136 | return Configuration::instance()->persistence()->refresh($object); 137 | } 138 | 139 | /** 140 | * @template T of object 141 | * 142 | * @param T $object 143 | * 144 | * @return T 145 | */ 146 | function delete(object $object): object 147 | { 148 | return Configuration::instance()->persistence()->delete($object); 149 | } 150 | 151 | /** 152 | * @template T 153 | * 154 | * @param callable():T $callback 155 | * 156 | * @return T 157 | */ 158 | function flush_after(callable $callback): mixed 159 | { 160 | return Configuration::instance()->persistence()->flushAfter($callback); 161 | } 162 | 163 | /** 164 | * Disable persisting factories globally. 165 | */ 166 | function disable_persisting(): void 167 | { 168 | Configuration::instance()->persistence()->disablePersisting(); 169 | } 170 | 171 | /** 172 | * Enable persisting factories globally. 173 | */ 174 | function enable_persisting(): void 175 | { 176 | Configuration::instance()->persistence()->enablePersisting(); 177 | } 178 | 179 | /** 180 | * @internal 181 | */ 182 | function initialize_proxy_object(mixed $what): void 183 | { 184 | match (true) { 185 | $what instanceof Proxy => $what->_initializeLazyObject(), 186 | \is_array($what) => \array_map(initialize_proxy_object(...), $what), 187 | default => true, // do nothing 188 | }; 189 | } 190 | -------------------------------------------------------------------------------- /src/Story.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\PersistenceNotAvailable; 15 | use Zenstruck\Foundry\Persistence\Exception\NoPersistenceStrategy; 16 | use Zenstruck\Foundry\Persistence\Exception\RefreshObjectFailed; 17 | use Zenstruck\Foundry\Persistence\Proxy; 18 | use Zenstruck\Foundry\Persistence\ProxyGenerator; 19 | 20 | /** 21 | * @author Kevin Bond 22 | */ 23 | abstract class Story 24 | { 25 | /** @var array */ 26 | private array $state = []; 27 | 28 | /** @var array */ 29 | private array $pools = []; 30 | 31 | /** 32 | * @param mixed[] $arguments 33 | */ 34 | final public function __call(string $method, array $arguments): mixed 35 | { 36 | return $this->getState($method); 37 | } 38 | 39 | /** 40 | * @param mixed[] $arguments 41 | */ 42 | final public static function __callStatic(string $name, array $arguments): mixed 43 | { 44 | return static::get($name); 45 | } 46 | 47 | final public static function get(string $state): mixed 48 | { 49 | return static::load()->getState($state); 50 | } 51 | 52 | /** 53 | * Get all the items in a pool. 54 | * 55 | * @return mixed[] 56 | */ 57 | final public static function getPool(string $pool): array 58 | { 59 | return static::load()->pools[$pool] ?? []; 60 | } 61 | 62 | /** 63 | * Get a random item from a pool. 64 | */ 65 | final public static function getRandom(string $pool): mixed 66 | { 67 | return static::getRandomSet($pool, 1)[0]; 68 | } 69 | 70 | /** 71 | * Get a random set of items from a pool. 72 | * 73 | * @return mixed[] 74 | */ 75 | final public static function getRandomSet(string $pool, int $number): array 76 | { 77 | if ($number < 1) { 78 | throw new \InvalidArgumentException(\sprintf('$number must be positive (%d given).', $number)); 79 | } 80 | 81 | return static::getRandomRange($pool, $number, $number); 82 | } 83 | 84 | /** 85 | * Get a random range of items from a pool. 86 | * 87 | * @return mixed[] 88 | */ 89 | final public static function getRandomRange(string $pool, int $min, int $max): array 90 | { 91 | if ($min < 0) { 92 | throw new \InvalidArgumentException(\sprintf('$min must be zero or greater (%d given).', $min)); 93 | } 94 | 95 | if ($max < $min) { 96 | throw new \InvalidArgumentException(\sprintf('$max (%d) cannot be less than $min (%d).', $max, $min)); 97 | } 98 | 99 | $values = static::getPool($pool); 100 | 101 | \shuffle($values); 102 | 103 | if (\count($values) < $max) { 104 | throw new \RuntimeException(\sprintf('At least %d items must be in pool "%s" (%d items found).', $max, $pool, \count($values))); 105 | } 106 | 107 | return \array_slice($values, 0, \mt_rand($min, $max)); 108 | } 109 | 110 | final public static function load(): static 111 | { 112 | return Configuration::instance()->stories->load(static::class); 113 | } 114 | 115 | abstract public function build(): void; 116 | 117 | final protected function addState(string $name, mixed $value, ?string $pool = null): static 118 | { 119 | $value = self::normalizeFactory($value); 120 | 121 | $this->state[$name] = $value; 122 | 123 | if ($pool) { 124 | $this->addToPool($pool, $value); 125 | } 126 | 127 | return $this; 128 | } 129 | 130 | final protected function getState(string $name): mixed 131 | { 132 | if (!\array_key_exists($name, $this->state)) { 133 | throw new \InvalidArgumentException(\sprintf('"%s" was not registered. Did you forget to call "%s::addState()"?', $name, static::class)); 134 | } 135 | 136 | if (!\is_object($this->state[$name])) { 137 | return $this->state[$name]; 138 | } 139 | 140 | try { 141 | $isProxy = $this->state[$name] instanceof Proxy; 142 | 143 | $unwrappedObject = ProxyGenerator::unwrap($this->state[$name]); 144 | Configuration::instance()->persistence()->refresh($unwrappedObject, force: true); 145 | 146 | return $isProxy ? ProxyGenerator::wrap($unwrappedObject) : $unwrappedObject; 147 | } catch (PersistenceNotAvailable|NoPersistenceStrategy|RefreshObjectFailed) { 148 | return $this->state[$name]; 149 | } 150 | } 151 | 152 | final protected function addToPool(string $pool, mixed $value): self 153 | { 154 | if ($value instanceof FactoryCollection) { 155 | $value = $value->create(); 156 | } 157 | 158 | if (!\is_array($value)) { 159 | $value = [$value]; 160 | } 161 | 162 | foreach ($value as $item) { 163 | $this->pools[$pool][] = self::normalizeFactory($item); 164 | } 165 | 166 | return $this; 167 | } 168 | 169 | private static function normalizeFactory(mixed $value): mixed 170 | { 171 | return $value instanceof Factory ? $value->create() : $value; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/Test/Factories.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\After; 15 | use PHPUnit\Framework\Attributes\Before; 16 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 17 | use Zenstruck\Foundry\Configuration; 18 | 19 | use function Zenstruck\Foundry\Persistence\initialize_proxy_object; 20 | 21 | /** 22 | * @author Kevin Bond 23 | */ 24 | trait Factories 25 | { 26 | /** 27 | * @internal 28 | * @before 29 | */ 30 | #[Before] 31 | public function _beforeHook(): void 32 | { 33 | $this->_bootFoundry(); 34 | $this->_loadDataProvidedProxies(); 35 | } 36 | 37 | /** 38 | * @internal 39 | * @after 40 | */ 41 | #[After] 42 | public static function _shutdownFoundry(): void 43 | { 44 | Configuration::shutdown(); 45 | } 46 | 47 | /** 48 | * @see \Zenstruck\Foundry\PHPUnit\BootFoundryOnDataProviderMethodCalled 49 | * @internal 50 | */ 51 | public static function _bootForDataProvider(): void 52 | { 53 | if (!\is_subclass_of(static::class, KernelTestCase::class)) { // @phpstan-ignore function.impossibleType, function.alreadyNarrowedType 54 | // unit test 55 | Configuration::bootForDataProvider(UnitTestConfig::build()); 56 | 57 | return; 58 | } 59 | 60 | // integration test 61 | Configuration::bootForDataProvider(static function(): Configuration { 62 | if (!static::getContainer()->has('.zenstruck_foundry.configuration')) { // @phpstan-ignore staticMethod.notFound 63 | throw new \LogicException('ZenstruckFoundryBundle is not enabled. Ensure it is added to your config/bundles.php.'); 64 | } 65 | 66 | return static::getContainer()->get('.zenstruck_foundry.configuration'); // @phpstan-ignore staticMethod.notFound, return.type 67 | }); 68 | } 69 | 70 | /** 71 | * @internal 72 | * @see \Zenstruck\Foundry\PHPUnit\ShutdownFoundryOnDataProviderMethodFinished 73 | */ 74 | public static function _shutdownAfterDataProvider(): void 75 | { 76 | if (\is_subclass_of(static::class, KernelTestCase::class)) { // @phpstan-ignore function.impossibleType, function.alreadyNarrowedType 77 | self::ensureKernelShutdown(); // @phpstan-ignore staticMethod.notFound 78 | static::$class = null; // @phpstan-ignore staticProperty.notFound 79 | static::$kernel = null; // @phpstan-ignore staticProperty.notFound 80 | static::$booted = false; // @phpstan-ignore staticProperty.notFound 81 | } 82 | Configuration::shutdown(); 83 | } 84 | 85 | /** 86 | * @internal 87 | */ 88 | private function _bootFoundry(): void 89 | { 90 | if (!\is_subclass_of(static::class, KernelTestCase::class)) { // @phpstan-ignore function.impossibleType, function.alreadyNarrowedType 91 | // unit test 92 | Configuration::boot(UnitTestConfig::build()); 93 | 94 | return; 95 | } 96 | 97 | // integration test 98 | Configuration::boot(static function(): Configuration { 99 | if (!static::getContainer()->has('.zenstruck_foundry.configuration')) { // @phpstan-ignore staticMethod.notFound 100 | throw new \LogicException('ZenstruckFoundryBundle is not enabled. Ensure it is added to your config/bundles.php.'); 101 | } 102 | 103 | return static::getContainer()->get('.zenstruck_foundry.configuration'); // @phpstan-ignore staticMethod.notFound, return.type 104 | }); 105 | } 106 | 107 | /** 108 | * If a persistent object has been created in a data provider, we need to initialize the proxy object, 109 | * which will trigger the object to be persisted. 110 | * 111 | * Otherwise, such test would not pass: 112 | * ```php 113 | * #[DataProvider('provide')] 114 | * public function testSomething(MyEntity $entity): void 115 | * { 116 | * MyEntityFactory::assert()->count(1); 117 | * } 118 | * 119 | * public static function provide(): iterable 120 | * { 121 | * yield [MyEntityFactory::createOne()]; 122 | * } 123 | * ``` 124 | * 125 | * Sadly, this cannot be done in a subscriber, since PHPUnit does not give access to the actual tests instances. 126 | * 127 | * @internal 128 | */ 129 | private function _loadDataProvidedProxies(): void 130 | { 131 | if (!\is_subclass_of(static::class, KernelTestCase::class)) { // @phpstan-ignore function.impossibleType, function.alreadyNarrowedType 132 | return; 133 | } 134 | 135 | $providedData = \method_exists($this, 'getProvidedData') ? $this->getProvidedData() : $this->providedData(); // @phpstan-ignore method.notFound 136 | 137 | initialize_proxy_object($providedData); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | function set(object $object, string $property, mixed $value): void 56 | { 57 | Hydrator::set($object, $property, $value); 58 | } 59 | 60 | /** 61 | * "Force get" (using reflection) an object property. 62 | */ 63 | function get(object $object, string $property): mixed 64 | { 65 | return Hydrator::get($object, $property); 66 | } 67 | 68 | /** 69 | * Create a "lazy" factory attribute which will only be evaluated 70 | * if used. 71 | * 72 | * @param callable():mixed $factory 73 | */ 74 | function lazy(callable $factory): LazyValue 75 | { 76 | return LazyValue::new($factory); 77 | } 78 | 79 | /** 80 | * Same as {@see lazy()} but subsequent evaluations will return the 81 | * same value. 82 | * 83 | * @param callable():mixed $factory 84 | */ 85 | function memoize(callable $factory): LazyValue 86 | { 87 | return LazyValue::memoize($factory); 88 | } 89 | 90 | /** 91 | * Allows to force a single property. 92 | */ 93 | function force(mixed $value): ForceValue 94 | { 95 | return new ForceValue($value); 96 | } 97 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /utils/psalm/FixProxyFactoryMethodsReturnType.php: -------------------------------------------------------------------------------- 1 | getMethodId()); 20 | 21 | if ($event->getCodebase()->classExtends($class, PersistentProxyObjectFactory::class)) { 22 | $templateType = $event->getCodebase()->classlikes->getStorageFor( 23 | $class 24 | )->template_extended_params[PersistentProxyObjectFactory::class]['T'] ?? null; 25 | 26 | if (!$templateType) { 27 | return; 28 | } 29 | 30 | $templateTypeAsString = $templateType->getId(); 31 | $proxyTypeHint = "{$templateTypeAsString}&Zenstruck\\Foundry\\Persistence\\Proxy<{$templateTypeAsString}>"; 32 | 33 | $methodsReturningObject = ['create', 'createone', 'find', 'findorcreate', 'first', 'last', 'random', 'randomorcreate']; 34 | if (\in_array($method, $methodsReturningObject, true)) { 35 | $event->setReturnTypeCandidate(Type::parseString($proxyTypeHint)); 36 | } 37 | 38 | $methodsReturningListOfObjects = ['all', 'createmany', 'createrange', 'createsequence', 'findby', 'randomrange', 'randomset']; 39 | if (\in_array($method, $methodsReturningListOfObjects, true)) { 40 | $event->setReturnTypeCandidate(Type::parseString("list<{$proxyTypeHint}>")); 41 | } 42 | 43 | $methodsReturningFactoryCollection = ['many', 'range', 'sequence']; 44 | if (\in_array($method, $methodsReturningFactoryCollection, true)) { 45 | $factoryCollectionClass = FactoryCollection::class; 46 | $event->setReturnTypeCandidate(Type::parseString("{$factoryCollectionClass}<{$proxyTypeHint}>")); 47 | } 48 | 49 | if ($method === 'repository' 50 | // if repository() method is overridden in userland, we should not change the return type 51 | && str_starts_with($event->getReturnTypeCandidate()->getId(), ProxyRepositoryDecorator::class) 52 | ) { 53 | $repositoryDecoratorClass = ProxyRepositoryDecorator::class; 54 | $doctrineRepositoryClass = ObjectRepository::class; 55 | $event->setReturnTypeCandidate( 56 | Type::parseString( 57 | "{$repositoryDecoratorClass}<{$templateTypeAsString}, {$doctrineRepositoryClass}<$templateTypeAsString>>" 58 | ) 59 | ); 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /utils/psalm/FoundryPlugin.php: -------------------------------------------------------------------------------- 1 | registerHooksFromClass(FixProxyFactoryMethodsReturnType::class); 15 | } 16 | } 17 | --------------------------------------------------------------------------------