diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa20c1c8a..3d7095ca6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: true matrix: - php: [7.4] + php: [7.2, 7.3, 7.4] stability: [prefer-lowest, prefer-stable] steps: - name: Checkout code @@ -45,11 +45,21 @@ jobs: - name: Install dependencies run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-suggest - - name: Run tests + - name: Run tests WITHOUT ZenstruckFoundryBundle run: vendor/bin/phpunit -v - - name: Run tests with DAMADoctrineTestBundle + - name: Run tests WITH ZenstruckFoundryBundle + run: vendor/bin/phpunit -v + env: + USE_FOUNDRY_BUNDLE: 1 + + - name: Run tests WITH DAMADoctrineTestBundle and WITHOUT ZenstruckFoundryBundle + run: vendor/bin/phpunit -v --configuration phpunit-dama-doctrine.xml.dist + + - name: Run tests WITH DAMADoctrineTestBundle and WITH ZenstruckFoundryBundle run: vendor/bin/phpunit -v --configuration phpunit-dama-doctrine.xml.dist + env: + USE_FOUNDRY_BUNDLE: 1 code-coverage: name: Code Coverage @@ -84,17 +94,29 @@ jobs: - name: Install dependencies run: composer update --prefer-dist --no-interaction --no-suggest - - name: Run code coverage + - name: Run code coverage WITHOUT ZenstruckFoundryBundle run: vendor/bin/phpunit -v --coverage-text --coverage-clover=coverage.clover - - name: Run code coverage with DAMADoctrineTestBundle - run: vendor/bin/phpunit -v --coverage-text --coverage-clover=dama-coverage.clover + - name: Run code coverage WITH ZenstruckFoundryBundle + run: vendor/bin/phpunit -v --coverage-text --coverage-clover=bundle-coverage.clover + env: + USE_FOUNDRY_BUNDLE: 1 + + - name: Run code coverage WITH DAMADoctrineTestBundle and WITHOUT ZenstruckFoundryBundle + run: vendor/bin/phpunit -v --coverage-text --coverage-clover=coverage-dama.clover + + - name: Run code coverage WITH DAMADoctrineTestBundle and WITH ZenstruckFoundryBundle + run: vendor/bin/phpunit -v --coverage-text --coverage-clover=bundle-coverage-dama.clover + env: + USE_FOUNDRY_BUNDLE: 1 - name: Send code coverage run: | wget https://scrutinizer-ci.com/ocular.phar php ocular.phar code-coverage:upload --format=php-clover coverage.clover - php ocular.phar code-coverage:upload --format=php-clover dama-coverage.clover + php ocular.phar code-coverage:upload --format=php-clover bundle-coverage.clover + php ocular.phar code-coverage:upload --format=php-clover coverage-dama.clover + php ocular.phar code-coverage:upload --format=php-clover bundle-coverage-dama.clover composer-validate: name: Validate composer.json diff --git a/.php_cs.dist b/.php_cs.dist index d9e6e88a9..a323932bf 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -39,6 +39,7 @@ return PhpCsFixer\Config::create() 'remove_inheritdoc' => true, ], 'function_declaration' => ['closure_function_spacing' => 'none'], + 'nullable_type_declaration_for_default_null_value' => true, )) ->setRiskyAllowed(true) ->setFinder($finder) diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 7e41f3b66..6508b2930 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -5,7 +5,7 @@ checks: tools: external_code_coverage: timeout: 900 - runs: 2 + runs: 4 build: nodes: analysis: diff --git a/README.md b/README.md index 05fcd822e..766183b61 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ public function test_can_post_a_comment(): void // 1. "Arrange" $post = PostFactory::new() // New Post factory ->published() // Make the post in a "published" state - ->persist([ // Instantiate Post object and persist + ->create([ // Instantiate Post object and persist 'slug' => 'post-a' // This test only requires the slug field - all other fields are random data ]) ; @@ -62,38 +62,36 @@ public function test_can_post_a_comment(): void 1. [Instantiate](#instantiate) 2. [Persist](#persist) 3. [Attributes](#attributes) - 4. [Events](#events) - 5. [Instantiator](#instantiator) - 6. [Immutable](#immutable) - 7. [Object Proxy](#object-proxy) - 8. [Repository Proxy](#repository-proxy) + 4. [Doctrine Relationships](#doctrine-relationships) + 5. [Events](#events) + 6. [Instantiator](#instantiator) + 7. [Immutable](#immutable) + 8. [Object Proxy](#object-proxy) + 9. [Repository Proxy](#repository-proxy) 6. [Model Factories](#model-factories) 1. [Generate](#generate) 2. [Usage](#usage) 3. [States](#states) 4. [Initialize](#initialize) 7. [Stories](#stories) -8. [Global State](#global-state) -9. [Performance Considerations](#performance-considerations) + 1. [Stories as Services](#stories-as-services) +8. [Seeding your Development Database](#seeding-your-development-database) +9. [Global Test State](#global-test-state) +10. [Full Default Bundle Configuration](#full-default-bundle-configuration) +11. [Using without the Bundle](#using-without-the-bundle) +12. [Performance Considerations](#performance-considerations) 1. [DAMADoctrineTestBundle](#damadoctrinetestbundle) 2. [Miscellaneous](#miscellaneous) -10. [Credit](#credit) +13. [Credit](#credit) ### Installation $ composer require zenstruck/foundry --dev To use the *Maker's*, ensure [Symfony MakerBundle](https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html) -is installed and enable the packaged bundle: +is installed. -```php -# config/bundles.php - -return [ - // ... - Zenstruck\Foundry\Bundle\ZenstruckFoundryBundle::class => ['dev' => true], -]; -``` +*If not using Symfony Flex, be sure to enable the bundle in your **test**/**dev** environments.* ### Test Traits @@ -155,12 +153,13 @@ faker()->email; // random email **NOTE**: You can register your own `Faker\Generator`: -```php -// tests/bootsrap.php -// ... -Zenstruck\Foundry\Factory::registerFaker( - Faker\Factory::create('fr_FR') -); +```yaml +# config/packages/dev/zenstruck_foundry.yaml and/or config/packages/test/zenstruck_foundry.yaml +zenstruck_foundry: + faker: + locale: fr_FR # set the locale + # or + service: my_faker # use your own instance of Faker\Generator for complete control ``` ### Sample Entities @@ -276,15 +275,22 @@ use function Zenstruck\Foundry\faker; use function Zenstruck\Foundry\instantiate; use function Zenstruck\Foundry\instantiate_many; -(new Factory(Post::class))->instantiate(); // instance of Post -(new Factory(Post::class))->instantiate(['title' => 'Post A']); // instance of Post with $title set to "Post A" +(new Factory(Post::class))->withoutPersisting()->create(); // instance of Proxy (unpersisted) wrapping Post +(new Factory(Post::class))->withoutPersisting()->create()->object(); // instance of Post -// array of 6 Post objects with random titles -(new Factory(Post::class))->instantiateMany(6, fn() => ['title' => faker()->sentence]); +// instance of Proxy wrapping Post with $title set to "Post A" +(new Factory(Post::class))->withoutPersisting()->create(['title' => 'Post A']); + +// array of 6 Proxy objects (unpersisted) wrapping Post objects with random titles +(new Factory(Post::class))->withoutPersisting()->createMany(6, function() { + return ['title' => faker()->sentence]; +}); // alternatively, use the helper functions instantiate(Post::class, ['title' => 'Post A']); -instantiate_many(6, Post::class, fn() => ['title' => faker()->sentence]); +instantiate_many(6, Post::class, function() { + return ['title' => faker()->sentence]; +}); ``` #### Persist @@ -296,35 +302,24 @@ section for more information on the returned `Proxy` object): use App\Entity\Post; use Zenstruck\Foundry\Factory; use function Zenstruck\Foundry\faker; -use function Zenstruck\Foundry\persist; -use function Zenstruck\Foundry\persist_many; +use function Zenstruck\Foundry\create; +use function Zenstruck\Foundry\create_many; // instance of Zenstruck\Foundry\Proxy wrapping Post with title "Post A" -(new Factory(Post::class))->persist(['title' => 'Post A']); - -// disable proxying (instance of Post) -(new Factory(Post::class))->persist(['title' => 'Post A'], false); +(new Factory(Post::class))->create(['title' => 'Post A']); // array of 6 Post Proxy objects with random titles -(new Factory(Post::class))->persistMany(6, fn() => ['title' => faker()->sentence]); +(new Factory(Post::class))->createMany(6, function() { + return ['title' => faker()->sentence]; +}); // alternatively, use the helper functions -persist(Post::class, ['title' => 'Post A']); -persist_many(6, Post::class, fn() => ['title' => faker()->sentence]); -``` - -You can globally disable object proxying during persisting: - -```php -// tests/bootstrap.php -// ... -Zenstruck\Foundry\Factory::proxyByDefault(false); +create(Post::class, ['title' => 'Post A']); +create_many(6, Post::class, function() { + return ['title' => faker()->sentence]; +}); ``` -**NOTE**: The persist operations require that a `Doctrine\Persistence\ManagerRegistry` be registered with -`Zenstruck\Foundry\PersistenceManager` via `\Zenstruck\Foundry\PersistenceManager::register($registry)`. If using the -[`Factories`](#test-traits) test trait, this is handled for you. - #### Attributes The attributes used to instantiate the object can be added several ways. Attributes can be an *array*, or a *callable* @@ -334,7 +329,7 @@ that returns an array. Using a *callable* helps with ensuring random data as the use App\Entity\Category; use App\Entity\Post; use Zenstruck\Foundry\Factory; -use function Zenstruck\Foundry\persist; +use function Zenstruck\Foundry\create; $post = (new Factory(Post::class, ['title' => 'Post A'])) ->withAttributes([ @@ -351,10 +346,10 @@ $post = (new Factory(Post::class, ['title' => 'Post A'])) 'published-at' => new \DateTime('last week'), // Proxies are automatically converted to their wrapped object - 'category' => persist(Category::class, ['name' => 'symfony']), + 'category' => create(Category::class, ['name' => 'symfony']), ]) - ->withAttributes(fn() => ['createdAt' => Factory::faker()->dateTime]) - ->instantiate(['title' => 'Different Title']) + ->withAttributes(function() { return ['createdAt' => Factory::faker()->dateTime]; }) + ->create(['title' => 'Different Title']) ; $post->getTitle(); // "Different Title" @@ -364,14 +359,64 @@ $post->getPublishedAt(); // \DateTime('last week') $post->getCreatedAt(); // random \DateTime ``` +#### Doctrine Relationships + +Assuming your entites follow the +[best practices for Doctrine Relationships](https://symfony.com/doc/current/doctrine/associations.html) and you are +using the [default instantiator](#instantiator), Foundry *just works* with doctrine relationships: + +```php +use function Zenstruck\Foundry\create; +use function Zenstruck\Foundry\factory; + +// ManyToOne +create(Post::class, [ + 'category' => $category, // $category is instance of Category +]); +create(Post::class, [ + // Proxy objects are converted to object before calling Post::setCategory() + 'category' => create(Category::class, ['name' => 'My Category']), +]); +create(Post::class, [ + // Factory objects are persisted before calling Post::setCategory() + 'category' => factory(Category::class, ['name' => 'My Category']), +]); + +// OneToMany +create(Category::class, [ + 'posts' => [ + $post, // $post is instance of Post, Category::addPost($post) will be called during instantiation + + // Proxy objects are converted to object before calling Category::addPost() + create(Post::class, ['title' => 'Post B', 'body' => 'body']), + + // Factory objects are persisted before calling Category::addPost() + factory(Post::class, ['title' => 'Post A', 'body' => 'body']), + ], +]); + +// ManyToMany +create(Post::class, [ + 'tags' => [ + $tag, // $tag is instance of Tag, Post::addTag($tag) will be called during instantiation + + // Proxy objects are converted to object before calling Post::addTag() + create(Tag::class, ['name' => 'My Tag']), + + // Factory objects are persisted before calling Post::addTag() + factory(Tag::class, ['name' => 'My Tag']), + ], +]); +``` + #### Events The following events can be added to factories. Multiple event callbacks can be added, they are run in the order they were added. ```php -use Doctrine\Persistence\ObjectManager; use Zenstruck\Foundry\Factory; +use Zenstruck\Foundry\Proxy; (new Factory(Post::class)) ->beforeInstantiate(function(array $attributes): array { @@ -384,15 +429,22 @@ use Zenstruck\Foundry\Factory; // $object is the instantiated object // $attributes contains the attributes used to instantiate the object and any extras }) - ->afterPersist(function(Post $object, array $attributes, ObjectManager $om) { + ->afterPersist(function(Proxy $object, array $attributes) { + /* @var Post $object */ // this event is only called if the object was persisted - // $object is the persisted object + // $proxy is a Proxy wrapping the persisted object + // $attributes contains the attributes used to instantiate the object and any extras + }) + + // if the first argument is type-hinted as the object, it will be passed to the closure (and not the proxy) + ->afterPersist(function(Post $object, array $attributes) { + // this event is only called if the object was persisted + // $object is the persisted Post object // $attributes contains the attributes used to instantiate the object and any extras - // $om is the ObjectManager used to persist $object }) // multiple events are allowed - ->beforeInstantiate(fn($attributes) => $attributes) + ->beforeInstantiate(function($attributes) { return $attributes; }) ->afterInstantiate(function() {}) ->afterPersist(function() {}) ; @@ -401,8 +453,19 @@ use Zenstruck\Foundry\Factory; #### Instantiator By default, objects are instantiated with the object's constructor. Attributes that match constructor arguments are -used. Remaining attributes are set to the object's matching properties (public/protected/private). Extra attributes -are ignored. You can customize the instantiator several ways: +used. Remaining attributes are set to the object using Symfony's PropertyAccess component (using setters). Any extra +attributes cause an exception to be thrown. + +When using the default instantiator, there are two attribute key prefixes to change behavior: + +```php +$post = (new Factory(Post::class))->create([ + 'force:body' => 'some body', // "force set" the body property (even private/protected, does not use setter) + 'optional:extra' => 'value', // attributes prefixed with "optional:" do not cause an exception +]); +``` + +You can customize the instantiator several ways: ```php use Zenstruck\Foundry\Instantiator; @@ -410,19 +473,20 @@ use Zenstruck\Foundry\Factory; // set the instantiator for the current factory (new Factory(Post::class)) - // instantiate by only using the constructor (no "force setting" attributes) - ->instantiator(Instantiator::onlyConstructor()) + // instantiate the object without calling the constructor + ->instantiateWith((new Instantiator())->withoutConstructor()) + + // extra attributes are ignored + ->instantiateWith((new Instantiator())->allowExtraAttributes()) - // instantiate the object without calling the constructor (only "force set" attributes - ->instantiator(Instantiator::withoutConstructor()) + // never use setters, always "force set" properties (even private/protected, does not use setter) + ->instantiateWith((new Instantiator())->alwaysForceProperties()) - // "strict" mode - extra attributes cause an exception - ->instantiator(Instantiator::default()->strict()) - ->instantiator(Instantiator::onlyConstructor()->strict()) - ->instantiator(Instantiator::withoutConstructor()->strict()) + // can combine the different "modes" + ->instantiateWith((new Instantiator())->withoutConstructor()->allowExtraAttributes()->alwaysForceProperties()) - // The instantiator is just a callable, you can provide your own - ->instantiator(function(array $attibutes, string $class): object { + // the instantiator is just a callable, you can provide your own + ->instantiateWith(function(array $attibutes, string $class): object { return new Post(); // ... your own logic }) ; @@ -431,12 +495,15 @@ use Zenstruck\Foundry\Factory; You can also customize the instantiator globally for all your factories (can still be overruled by factory instance instantiators): -```php -// tests/bootstrap.php -// ... -Zenstruck\Foundry\Factory::registerDefaultInstantiator( - Zenstruck\Foundry\Instantiator::withoutConstructor() -); +```yaml +# config/packages/dev/zenstruck_foundry.yaml and/or config/packages/test/zenstruck_foundry.yaml +zenstruck_foundry: + instantiator: + without_constructor: true # always instantiate objects without calling the constructor + allow_extra_attributes: true # always ignore extra attributes + always_force_properties: true # always "force set" properties + # or + service: my_instantiator # your own invokable service for complete control ``` #### Immutable @@ -449,7 +516,7 @@ use Zenstruck\Foundry\Factory; $factory = new Factory(Post::class); $factory->withAttributes([]); // new object -$factory->instantiator(function () {}); // new object +$factory->instantiateWith(function () {}); // new object $factory->beforeInstantiate(function () {}); // new object $factory->afterInstantiate(function () {}); // new object $factory->afterPersist(function () {}); // new object @@ -457,15 +524,16 @@ $factory->afterPersist(function () {}); // new object #### Object Proxy -By default, objects persisted by a factory are wrapped in a special [Proxy](src/Proxy.php) object. These objects help +Objects created by a factory are wrapped in a special [Proxy](src/Proxy.php) object. These objects help with your "post-act" test assertions. Almost all calls to Proxy methods, first refresh the object from the database (even if your entity manager has been cleared). ```php use App\Entity\Post; -use function Zenstruck\Foundry\persist; +use Zenstruck\Foundry\Proxy; +use function Zenstruck\Foundry\create; -$post = persist(Post::class, ['title' => 'My Title']); // instance of Zenstruck\Foundry\Proxy +$post = create(Post::class, ['title' => 'My Title']); // instance of Zenstruck\Foundry\Proxy // get the wrapped object $post->object(); // instance of Post @@ -485,15 +553,28 @@ $post->setTitle('New Title'); $post->save(); /** - * CAVEAT - when calling multiple methods that change the object state, the previous state will be lost because - * of auto-refreshing. Use "withoutAutoRefresh()" to overcome this. + * CAVEAT - When calling multiple methods that change the object state, the previous state will be lost because + * of auto-refreshing. Use "disableAutoRefresh()" or "withoutAutoRefresh()" to overcome this. */ -$post->withoutAutoRefresh(); // disable auto-refreshing +$post->disableAutoRefresh(); // disable auto-refreshing $post->refresh(); // manually refresh $post->setTitle('New Title'); // won't be auto-refreshed $post->setBody('New Body'); // won't be auto-refreshed -$post->save(); // save changes -$post->withAutoRefresh(); // re-enable auto-refreshing +$post->save(); // save changes (auto-refreshing re-enabled) + +// alternatively, use "withAutoRefresh()" - auto-refreshing disabled before running callback and re-enabled after +$post->withoutAutoRefresh(function(Proxy $post) { + /* @var Post $post */ + $post->setTitle('New Title'); + $post->setBody('New Body'); + $post->save(); +}); + +// if the first argument is type-hinted as the wrapped object, it will be passed to the closure (and not the proxy) +$post->withoutAutoRefresh(function(Post $post) { + $post->setTitle('New Title'); + $post->setBody('New Body'); +})->save(); // set private/protected properties $post->forceSet('createdAt', new \DateTime()); @@ -501,8 +582,9 @@ $post->forceSet('created_at', new \DateTime()); // can use snake case $post->forceSet('created-at', new \DateTime()); // can use kebab case /** - * CAVEAT - when force setting multiple properties, the previous set's changes will be lost because - * of auto-refreshing. Use "withoutAutoRefresh()" or forceSetAll() to overcome this. + * CAVEAT - When force setting multiple properties, the previous set's changes will be lost because + * of auto-refreshing. Use "disableAutoRefresh()"/"withoutAutoRefresh()" (shown above) or "forceSetAll()" + * to overcome this. */ $post->forceSetAll([ 'title' => 'Different title', @@ -514,16 +596,7 @@ $post->forceGet('createdAt'); $post->forceGet('created_at'); $post->forceGet('created-at'); -$post->repository(); // instance of Zenstruck\Foundry\RepositoryProxy wrapping PostRepository -$post->repository(false); // instance of un-proxied PostRepository -``` - -You can globally disable auto-refreshing of proxies: - -```php -// tests/bootstrap.php -// ... -Zenstruck\Foundry\Proxy::autoRefreshByDefault(false); +$post->repository(); // instance of RepositoryProxy wrapping PostRepository ``` #### Repository Proxy @@ -534,7 +607,7 @@ This library provides a Repository Proxy that wraps your object repositories to use App\Entity\Post; use function Zenstruck\Foundry\repository; -// instance of Zenstruck\Foundry\RepositoryProxy that wraps App\Repository\PostRepository +// instance of RepositoryProxy that wraps PostRepository $repository = repository(Post::class); // PHPUnit assertions @@ -551,6 +624,9 @@ $repository->assertNotExists(['title' => 'My Title']); $repository->getCount(); // number of rows in the database table $repository->first(); // get the first object (wrapped in a object proxy) $repository->truncate(); // delete all rows in the database table +$repository->random(); // get a random object +$repository->randomSet(5); // get 5 random objects +$repository->randomRange(0, 5); // get 0-5 random objects // instance of ObjectRepository (all returned objects are proxied) $repository->find(1); // Proxy|Post|null @@ -561,9 +637,6 @@ $repository->findBy(['title' => 'My Title']); // Proxy[]|Post[] // can call methods on the underlying repository (returned objects are proxied) $repository->findOneByTitle('My Title'); // Proxy|Post|null - -// get repository without proxying -repository(Post::class, false); // instance of PostRepository ``` ### Model Factories @@ -576,12 +649,17 @@ Create a model factory for one of your entities with the maker command: $ bin/console make:factory Post -**NOTE**: Calling `make:factory` without arguments displays a list of registered entities in your app to choose from. +**NOTES**: + +1. Creates `PostFactory.php` in `src/Factory`, add `--test` flag to create in `tests/Factory`. +2. Calling `make:factory` without arguments displays a list of registered entities in your app to choose from. Customize the generated model factory (if not using the maker command, this is what you will need to create manually): ```php -namespace App\Tests\Factories; +// src/Factory/PostFactory.php + +namespace App\Factory; use App\Entity\Post; use App\Repository\PostRepository; @@ -590,15 +668,15 @@ use Zenstruck\Foundry\ModelFactory; use Zenstruck\Foundry\Proxy; /** - * @method static Post make($attributes = []) - * @method static Post[] makeMany(int $number, $attributes = []) - * @method static Post|Proxy create($attributes = [], ?bool $proxy = null) - * @method static Post[]|Proxy[] createMany(int $number, $attributes = [], ?bool $proxy = null) - * @method static PostRepository|RepositoryProxy repository(bool $proxy = true) + * @method static Post|Proxy findOrCreate(array $attributes) + * @method static Post|Proxy random() + * @method static Post[]|Proxy[] randomSet(int $number) + * @method static Post[]|Proxy[] randomRange(int $min, int $max) + * @method static PostRepository|RepositoryProxy repository() * @method Post instantiate($attributes = []) * @method Post[] instantiateMany(int $number, $attributes = []) - * @method Post|Proxy persist($attributes = [], ?bool $proxy = null) - * @method Post[]|Proxy[] persistMany(int $number, $attributes = [], ?bool $proxy = null) + * @method Post|Proxy create($attributes = []) + * @method Post[]|Proxy[] createMany(int $number, $attributes = []) */ final class PostFactory extends ModelFactory { @@ -625,27 +703,35 @@ Model factories extend `Zenstruck/Foundry/Factory` so all [methods and functiona available. ```php -use App\Tests\Factories\PostFactory; +use App\Factory\PostFactory; -$post = PostFactory::new()->persist(); // Proxy with random data from `getDefaults()` +$post = PostFactory::new()->create(); // Proxy with random data from `getDefaults()` $post->getTitle(); // getTitle() can be autocompleted by your IDE! -PostFactory::new()->persist(['title' => 'My Title']); // override defaults -PostFactory::new(['title' => 'My Title'])->persist(); // alternative to above +PostFactory::new()->create(['title' => 'My Title']); // override defaults +PostFactory::new(['title' => 'My Title'])->create(); // alternative to above // find a persisted object for the given attributes, if not found, create with the attributes PostFactory::findOrCreate(['title' => 'My Title']); // instance of Proxy|Post -PostFactory::repository(); // Instance of RepositoryProxy|PostRepository +// get a random object that has been persisted +PostFactory::random(); // instance of Proxy|Post -// create objects directly -PostFactory::make(); // instance of Post -PostFactory::make(['title' => 'My Title']); // instance of Post with $title = 'My Title' -PostFactory::makeMany(3); // array of 3 Post objects -PostFactory::create(); // instance of Proxy|Post -PostFactory::create(['title' => 'My Title']); // instance of Proxy|Post with Post::$title = 'My Title' -PostFactory::createMany(3); // array of 3 Proxy|Post objects +// get a random set of objects that have been persisted +PostFactory::randomSet(4); // array containing 4 instances of Proxy|Post's + +// random range of persisted objects +PostFactory::randomRange(0, 5); // array containing 0-5 instances of Proxy|Post's + +PostFactory::repository(); // Instance of RepositoryProxy wrapping PostRepository + +// instantiate objects (without persisting) +PostFactory::new()->withoutPersisting()->create(); // instance of Proxy (unpersisted) wrapping Post object +PostFactory::new()->withoutPersisting()->create()->object(); // Post object + +// instance of Proxy (unpersisted) wrapping Post object with $title = 'My Title' +PostFactory::new()->withoutPersisting()->create(['title' => 'My Title']); ``` #### States @@ -654,11 +740,11 @@ You can add any methods you want to your model factories (ie static methods that you can add "states": ```php -namespace App\Tests\Factories; +namespace App\Factory; use App\Entity\Post; use Zenstruck\Foundry\ModelFactory; -use function Zenstruck\Foundry\persist; +use function Zenstruck\Foundry\create; final class PostFactory extends ModelFactory { @@ -678,7 +764,7 @@ final class PostFactory extends ModelFactory { return $this->afterInstantiate(function (Post $post) use ($tags) { foreach ($tags as $tag) { - $post->addTag(persist(Tag::class, ['name' => $tag])->object()); + $post->addTag(create(Tag::class, ['name' => $tag])->object()); } }); } @@ -695,19 +781,19 @@ final class PostFactory extends ModelFactory You can use states to make your tests very explicit to improve readability: ```php -$post = PostFactory::new()->unpublished()->persist(); -$post = PostFactory::new()->withViewCount(3)->persist(); -$post = PostFactory::new()->withTags('dev', 'design')->persist(); +$post = PostFactory::new()->unpublished()->create(); +$post = PostFactory::new()->withViewCount(3)->create(); +$post = PostFactory::new()->withTags('dev', 'design')->create(); // combine multiple states $post = PostFactory::new() ->withTags('dev') ->unpublished() - ->persist() + ->create() ; // states that don't require arguments can be added as strings to PostFactory::new() -$post = PostFactory::new('published', 'withViewCount')->persist(); +$post = PostFactory::new('published', 'withViewCount')->create(); ``` #### Initialize @@ -715,11 +801,11 @@ $post = PostFactory::new('published', 'withViewCount')->persist(); You can override your model factory's `initialize()` method to add default state/logic: ```php -namespace App\Tests\Factories; +namespace App\Factory; use App\Entity\Post; use Zenstruck\Foundry\ModelFactory; -use function Zenstruck\Foundry\persist; +use function Zenstruck\Foundry\create; final class PostFactory extends ModelFactory { @@ -729,7 +815,7 @@ final class PostFactory extends ModelFactory { return $this ->published() // published by default - ->instantiator(function (array $attributes) { + ->instantiateWith(function (array $attributes) { return new Post(); // custom instantiation for this factory }) ->afterPersist(function () {}) // default event for this factory @@ -747,17 +833,21 @@ Create a story using the maker command: $ bin/console make:story Post -This creates a *Story* object in `tests/Stories`. Modify the *build* method to set the state for this story: +**NOTE**: Creates `PostStory.php` in `src/Story`, add `--test` flag to create in `tests/Story`. + +Modify the *build* method to set the state for this story: ```php -namespace App\Tests\Stories; +// src/Story/PostStory.php -use App\Tests\Factories\PostFactory; +namespace App\Story; + +use App\Factory\PostFactory; use Zenstruck\Foundry\Story; final class PostStory extends Story { - protected function build(): void + public function build(): void { // use "add" to have the object managed by the story and can be accessed in // tests and other stories via PostStory::postA() @@ -789,11 +879,91 @@ public function test_using_story(): void } ``` -**NOTE**: Story state objects are always proxies even if proxying was disabled. - **NOTE**: Story state and objects persisted by them are reset after each test. -### Global State +#### Stories as Services + +If you stories require dependencies, you can define them as a service: + +```php +// src/Story/PostStory.php + +namespace App\Story; + +use App\Factory\PostFactory; +use App\Service\ServiceA; +use App\Service\ServiceB; +use Zenstruck\Foundry\Story; + +final class PostStory extends Story +{ + private $serviceA; + private $serviceB; + + public function __construct(ServiceA $serviceA, ServiceB $serviceB) + { + $this->serviceA = $serviceA; + $this->serviceB = $serviceB; + } + + public function build(): void + { + // can use $this->serviceA, $this->serviceB here to help build this story + } +} +``` + +If using a standard Symfony Flex app, this will be autowired/autoconfigured but if not, register the service and tag +with `foundry.story`. + +**NOTE:** The provided bundle is required for stories as services. + +### Seeding your Development Database + +Foundry works out of the box with [DoctrineFixturesBundle](https://symfony.com/doc/master/bundles/DoctrineFixturesBundle/index.html). +You can simply use your factory's and story's right within your fixture files: + +```php +// src/DataFixtures/AppFixtures.php +namespace App\DataFixtures; + +use App\Factory\CategoryFactory; +use App\Factory\PostFactory; +use App\Factory\TagFactory; +use App\Story\GlobalStory; +use Doctrine\Bundle\FixturesBundle\Fixture; +use Doctrine\Persistence\ObjectManager; + +class AppFixtures extends Fixture +{ + public function load(ObjectManager $manager) + { + GlobalStory::load(); + + // create 10 Category's + CategoryFactory::new()->createMany(10); + + // create 20 Tag's + TagFactory::new()->createMany(20); + + // create 50 Post's + PostFactory::new()->createMany(50, function() { + return [ + // each Post will have a random Category (created above) + 'category' => CategoryFactory::random(), + + // each Post will between 0 and 6 Tag's (created above) + 'tags' => TagFactory::randomRange(0, 6), + ]; + }); + } +} +``` + +Run the [`doctrine:fixtures:load`](https://symfony.com/doc/master/bundles/DoctrineFixturesBundle/index.html#loading-fixtures) +as normal to seed your database. + +### Global Test State If you have an initial database state you want for all tests, you can set this in your `tests/bootstrap.php`: @@ -801,7 +971,7 @@ If you have an initial database state you want for all tests, you can set this i // tests/bootstrap.php // ... -Zenstruck\Foundry\Test\GlobalState::add(function () { +Zenstruck\Foundry\Test\TestState::addGlobalState(function () { CategoryFactory::create(['name' => 'php']); CategoryFactory::create(['name' => 'symfony']); }); @@ -813,13 +983,69 @@ To avoid your boostrap file from becoming too complex, it is best to wrap your g // tests/bootstrap.php // ... -Zenstruck\Foundry\Test\GlobalState::add(function () { +Zenstruck\Foundry\Test\TestState::addGlobalState(function () { GlobalStory::load(); }); ``` **NOTE**: You can still access *Global State Stories* objects in your tests. They are still only loaded once. +### Full Default Bundle Configuration + +```yaml +zenstruck_foundry: + + # Configure faker to be used by your factories. + faker: + + # Change the default faker locale. + locale: null # Example: fr_FR + + # Customize the faker service. + service: null # Example: my_faker + + # Configure the default instantiator used by your factories. + instantiator: + + # Whether or not to call an object's constructor during instantiation. + without_constructor: null + + # Whether or not to allow extra attributes. + allow_extra_attributes: null + + # Whether or not to skip setters and force set object properties (public/private/protected) directly. + always_force_properties: null + + # Customize the instantiator service. + service: null # Example: my_instantiator +``` + +### Using without the Bundle + +The provided bundle is not strictly required to use Foundry. You can have all your factories, stories, and configuration +live in your `tests/` directory. + +The best place to configure Foundry without the bundle is in your `tests/bootstrap.php` file: + +```php +// tests/bootstrap.php +// ... + +// required when not using the bundle so the test traits know not to look for it. +Zenstruck\Foundry\Test\TestState::withoutBundle(); + +// configure a default instantiator +Zenstruck\Foundry\Test\TestState::setInstantiator( + (new Zenstruck\Foundry\Instantiator()) + ->withoutConstructor() + ->allowExtraAttributes() + ->alwaysForceProperties() +); + +// configure a custom faker +Zenstruck\Foundry\Test\TestState::setFaker(Faker\Factory::create('fr_FR')); +``` + ### Performance Considerations The following are possible options to improve the speed of your test suite. @@ -834,7 +1060,7 @@ Follow its documentation to install. Foundry's `ResetDatabase` trait detects whe accordingly. Your database is still reset before running your test suite but the schema isn't reset before each test (just the first). -**NOTE**: If using [Global State](#global-state), it is persisted to the database (not in a transaction) before your +**NOTE**: If using [Global Test State](#global-test-state), it is persisted to the database (not in a transaction) before your test suite is run. This could further improve test speed if you have a complex global state. #### Miscellaneous diff --git a/composer.json b/composer.json index ed090ba6d..0c5b61b50 100644 --- a/composer.json +++ b/composer.json @@ -12,15 +12,17 @@ } ], "require": { - "php": ">=7.4", + "php": ">=7.2.5", "doctrine/persistence": "^1.3.3", - "fzaninotto/faker": "^1.5" + "fzaninotto/faker": "^1.5", + "symfony/property-access": "^3.4|^4.4|^5.0" }, "require-dev": { "dama/doctrine-test-bundle": "^6.0", "doctrine/doctrine-bundle": "^2.0", "doctrine/orm": "^2.7", - "phpunit/phpunit": "^7.5", + "matthiasnoback/symfony-dependency-injection-test": "^4.1", + "phpunit/phpunit": "^8.5", "symfony/framework-bundle": "^4.4|^5.0", "symfony/maker-bundle": "^1.0" }, diff --git a/phpunit-dama-doctrine.xml.dist b/phpunit-dama-doctrine.xml.dist index cfc6cae12..6fc1988c5 100644 --- a/phpunit-dama-doctrine.xml.dist +++ b/phpunit-dama-doctrine.xml.dist @@ -2,7 +2,7 @@ - + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 8dac95aca..78c48535e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,7 @@ + */ +final class Configuration implements ConfigurationInterface +{ + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('zenstruck_foundry'); + + $treeBuilder->getRootNode() + ->children() + ->arrayNode('faker') + ->addDefaultsIfNotSet() + ->info('Configure faker to be used by your factories.') + ->validate() + ->ifTrue(static function(array $v) { + return $v['locale'] && $v['service']; + }) + ->thenInvalid('Cannot set faker locale when using custom service.') + ->end() + ->children() + ->scalarNode('locale') + ->defaultNull() + ->info('Change the default faker locale.') + ->example('fr_FR') + ->end() + ->scalarNode('service') + ->defaultNull() + ->info('Customize the faker service.') + ->example('my_faker') + ->end() + ->end() + ->end() + ->arrayNode('instantiator') + ->addDefaultsIfNotSet() + ->info('Configure the default instantiator used by your factories.') + ->validate() + ->ifTrue(static function(array $v) { + return $v['service'] && $v['without_constructor']; + }) + ->thenInvalid('Cannot set "without_constructor" when using custom service.') + ->end() + ->validate() + ->ifTrue(static function(array $v) { + return $v['service'] && $v['allow_extra_attributes']; + }) + ->thenInvalid('Cannot set "allow_extra_attributes" when using custom service.') + ->end() + ->validate() + ->ifTrue(static function(array $v) { + return $v['service'] && $v['always_force_properties']; + }) + ->thenInvalid('Cannot set "always_force_properties" when using custom service.') + ->end() + ->children() + ->booleanNode('without_constructor') + ->defaultNull() + ->info('Whether or not to call an object\'s constructor during instantiation.') + ->end() + ->booleanNode('allow_extra_attributes') + ->defaultNull() + ->info('Whether or not to allow extra attributes.') + ->end() + ->booleanNode('always_force_properties') + ->defaultNull() + ->info('Whether or not to skip setters and force set object properties (public/private/protected) directly.') + ->end() + ->scalarNode('service') + ->defaultNull() + ->info('Customize the instantiator service.') + ->example('my_instantiator') + ->end() + ->end() + ->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/Bundle/DependencyInjection/ZenstruckFoundryExtension.php b/src/Bundle/DependencyInjection/ZenstruckFoundryExtension.php index 3e5592f19..345af8c81 100644 --- a/src/Bundle/DependencyInjection/ZenstruckFoundryExtension.php +++ b/src/Bundle/DependencyInjection/ZenstruckFoundryExtension.php @@ -5,17 +5,61 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; +use Zenstruck\Foundry\Story; /** * @author Kevin Bond */ -final class ZenstruckFoundryExtension extends Extension +final class ZenstruckFoundryExtension extends ConfigurableExtension { - public function load(array $configs, ContainerBuilder $container) + protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void { $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); - $loader->load('makers.xml'); + $loader->load('services.xml'); + + $container->registerForAutoconfiguration(Story::class) + ->addTag('foundry.story') + ; + + $this->configureFaker($mergedConfig['faker'], $container); + $this->configureDefaultInstantiator($mergedConfig['instantiator'], $container); + } + + private function configureFaker(array $config, ContainerBuilder $container): void + { + if ($config['service']) { + $container->setAlias('zenstruck_foundry.faker', $config['service']); + + return; + } + + if ($config['locale']) { + $container->getDefinition('zenstruck_foundry.faker')->addArgument($config['locale']); + } + } + + private function configureDefaultInstantiator(array $config, ContainerBuilder $container): void + { + if ($config['service']) { + $container->setAlias('zenstruck_foundry.default_instantiator', $config['service']); + + return; + } + + $definition = $container->getDefinition('zenstruck_foundry.default_instantiator'); + + if ($config['without_constructor']) { + $definition->addMethodCall('withoutConstructor'); + } + + if ($config['allow_extra_attributes']) { + $definition->addMethodCall('allowExtraAttributes'); + } + + if ($config['always_force_properties']) { + $definition->addMethodCall('alwaysForceProperties'); + } } } diff --git a/src/Bundle/Maker/MakeFactory.php b/src/Bundle/Maker/MakeFactory.php index 9746d05ac..0d4470090 100644 --- a/src/Bundle/Maker/MakeFactory.php +++ b/src/Bundle/Maker/MakeFactory.php @@ -12,13 +12,15 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; /** * @author Kevin Bond */ final class MakeFactory extends AbstractMaker { - private ManagerRegistry $managerRegistry; + /** @var ManagerRegistry */ + private $managerRegistry; public function __construct(ManagerRegistry $managerRegistry) { @@ -35,6 +37,7 @@ public function configureCommand(Command $command, InputConfiguration $inputConf $command ->setDescription('Creates a custom factory for a Doctrine entity class') ->addArgument('entity', InputArgument::OPTIONAL, 'Entity class to create a factory for') + ->addOption('test', null, InputOption::VALUE_NONE, 'Create in tests/ instead of src/') ; $inputConfig->setArgumentAsNonInteractive('entity'); @@ -54,6 +57,11 @@ public function interact(InputInterface $input, ConsoleStyle $io, Command $comma public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void { + if (!$input->getOption('test')) { + $io->text('// Note: pass --test if you want to generate factories in your tests/ directory'); + $io->newLine(); + } + $class = $input->getArgument('entity'); if (!\class_exists($class)) { @@ -67,7 +75,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $entity = new \ReflectionClass($class); $factory = $generator->createClassNameDetails( $entity->getShortName(), - 'Tests\\Factories\\', + $input->getOption('test') ? 'Tests\\Factory' : 'Factory', 'Factory' ); @@ -104,7 +112,6 @@ public function configureDependencies(DependencyBuilder $dependencies): void private function entityChoices(): array { - // todo remove choices that already have a factory $choices = []; foreach ($this->managerRegistry->getManagers() as $manager) { diff --git a/src/Bundle/Maker/MakeStory.php b/src/Bundle/Maker/MakeStory.php index e06ccb6d7..f1ed64fce 100644 --- a/src/Bundle/Maker/MakeStory.php +++ b/src/Bundle/Maker/MakeStory.php @@ -10,6 +10,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; /** * @author Kevin Bond @@ -26,14 +27,20 @@ public function configureCommand(Command $command, InputConfiguration $inputConf $command ->setDescription('Creates a factory story') ->addArgument('name', InputArgument::OPTIONAL, 'The name of the story class (e.g. DefaultCategoriesStory)') + ->addOption('test', null, InputOption::VALUE_NONE, 'Create in tests/ instead of src/') ; } public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void { + if (!$input->getOption('test')) { + $io->text('// Note: pass --test if you want to generate stories in your tests/ directory'); + $io->newLine(); + } + $storyClassNameDetails = $generator->createClassNameDetails( $input->getArgument('name'), - 'Tests\\Stories\\', + $input->getOption('test') ? 'Tests\\Story' : 'Story', 'Story' ); diff --git a/src/Bundle/Resources/config/makers.xml b/src/Bundle/Resources/config/makers.xml deleted file mode 100644 index a137ef8f7..000000000 --- a/src/Bundle/Resources/config/makers.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/Bundle/Resources/config/services.xml b/src/Bundle/Resources/config/services.xml new file mode 100644 index 000000000..e4905e309 --- /dev/null +++ b/src/Bundle/Resources/config/services.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bundle/Resources/skeleton/Factory.tpl.php b/src/Bundle/Resources/skeleton/Factory.tpl.php index 5a80bbf34..2a74e08a9 100644 --- a/src/Bundle/Resources/skeleton/Factory.tpl.php +++ b/src/Bundle/Resources/skeleton/Factory.tpl.php @@ -10,24 +10,23 @@ use Zenstruck\Foundry\Proxy; /** - * @method static getShortName() ?> make($attributes = []) - * @method static getShortName() ?>[] makeMany(int $number, $attributes = []) - * @method static getShortName() ?>|Proxy create($attributes = [], ?bool $proxy = null) * @method static getShortName() ?>|Proxy findOrCreate(array $attributes) - * @method static getShortName() ?>[]|Proxy[] createMany(int $number, $attributes = [], ?bool $proxy = null) - * @method static getShortName() ?>|RepositoryProxy repository(bool $proxy = true) + * @method static getShortName() ?>|Proxy random() + * @method static getShortName() ?>[]|Proxy[] randomSet(int $number) + * @method static getShortName() ?>[]|Proxy[] randomRange(int $min, int $max) + * @method static getShortName() ?>|RepositoryProxy repository() * @method getShortName() ?> instantiate($attributes = []) * @method getShortName() ?>[] instantiateMany(int $number, $attributes = []) - * @method getShortName() ?>|Proxy persist($attributes = [], ?bool $proxy = null) - * @method getShortName() ?>[]|Proxy[] persistMany(int $number, $attributes = [], ?bool $proxy = null) + * @method getShortName() ?>|Proxy create($attributes = []) + * @method getShortName() ?>[]|Proxy[] createMany(int $number, $attributes = []) */ final class extends ModelFactory { protected function getDefaults(): array { return [ - // TODO add your default values here (link to docs) + // TODO add your default values here (https://github.com/zenstruck/foundry#model-factories) ]; } diff --git a/src/Bundle/Resources/skeleton/Story.tpl.php b/src/Bundle/Resources/skeleton/Story.tpl.php index 72db09125..c2bfc5e18 100644 --- a/src/Bundle/Resources/skeleton/Story.tpl.php +++ b/src/Bundle/Resources/skeleton/Story.tpl.php @@ -6,8 +6,8 @@ final class extends Story { - protected function build(): void + public function build(): void { - // TODO build your story here (link to docs) + // TODO build your story here (https://github.com/zenstruck/foundry#stories) } } diff --git a/src/Bundle/ZenstruckFoundryBundle.php b/src/Bundle/ZenstruckFoundryBundle.php index 99211fa5e..48c5ad8ce 100644 --- a/src/Bundle/ZenstruckFoundryBundle.php +++ b/src/Bundle/ZenstruckFoundryBundle.php @@ -3,10 +3,16 @@ namespace Zenstruck\Foundry\Bundle; use Symfony\Component\HttpKernel\Bundle\Bundle; +use Zenstruck\Foundry\Configuration; +use Zenstruck\Foundry\Factory; /** * @author Kevin Bond */ final class ZenstruckFoundryBundle extends Bundle { + public function boot() + { + Factory::boot($this->container->get(Configuration::class)); + } } diff --git a/src/Configuration.php b/src/Configuration.php new file mode 100644 index 000000000..b77394477 --- /dev/null +++ b/src/Configuration.php @@ -0,0 +1,99 @@ + + */ +final class Configuration +{ + /** @var ManagerRegistry */ + private $managerRegistry; + + /** @var StoryManager */ + private $stories; + + /** @var Faker\Generator */ + private $faker; + + /** @var callable */ + private $instantiator; + + public function __construct(ManagerRegistry $managerRegistry, StoryManager $storyManager) + { + $this->managerRegistry = $managerRegistry; + $this->stories = $storyManager; + $this->faker = Faker\Factory::create(); + $this->instantiator = new Instantiator(); + } + + public function stories(): StoryManager + { + return $this->stories; + } + + public function faker(): Faker\Generator + { + return $this->faker; + } + + public function instantiator(): callable + { + return $this->instantiator; + } + + public function setManagerRegistry(ManagerRegistry $managerRegistry): self + { + $this->managerRegistry = $managerRegistry; + + return $this; + } + + public function setInstantiator(callable $instantiator): self + { + $this->instantiator = $instantiator; + + return $this; + } + + public function setFaker(Faker\Generator $faker): self + { + $this->faker = $faker; + + return $this; + } + + /** + * @param object|string $objectOrClass + */ + public function repositoryFor($objectOrClass): RepositoryProxy + { + if ($objectOrClass instanceof Proxy) { + $objectOrClass = $objectOrClass->object(); + } + + if (!\is_string($objectOrClass)) { + $objectOrClass = \get_class($objectOrClass); + } + + return new RepositoryProxy($this->managerRegistry->getRepository($objectOrClass)); + } + + /** + * @param object|string $objectOrClass + */ + public function objectManagerFor($objectOrClass): ObjectManager + { + $class = \is_string($objectOrClass) ? $objectOrClass : \get_class($objectOrClass); + + if (!$objectManager = $this->managerRegistry->getManagerForClass($class)) { + throw new \RuntimeException(\sprintf('No object manager registered for "%s".', $class)); + } + + return $objectManager; + } +} diff --git a/src/Factory.php b/src/Factory.php index 5777c7415..87ebdfdbf 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -9,27 +9,29 @@ */ class Factory { - /** @var callable|null */ - private static $defaultInstantiator; - private static bool $proxyByDefault = true; - private static ?Faker\Generator $faker = null; + /** @var Configuration|null */ + private static $configuration; - private string $class; + /** @var string */ + private $class; /** @var callable|null */ private $instantiator; + /** @var bool */ + private $persist = true; + /** @var array */ - private array $attributeSet = []; + private $attributeSet = []; /** @var callable[] */ - private array $beforeInstantiate = []; + private $beforeInstantiate = []; /** @var callable[] */ - private array $afterInstantiate = []; + private $afterInstantiate = []; /** @var callable[] */ - private array $afterPersist = []; + private $afterPersist = []; /** * @param array|callable $defaultAttributes @@ -40,40 +42,26 @@ public function __construct(string $class, $defaultAttributes = []) $this->attributeSet[] = $defaultAttributes; } - /** - * @param array|callable $attributes - */ - final public function instantiate($attributes = []): object - { - return $this->doInstantiate($attributes, false); - } - - /** - * @param array|callable $attributes - * - * @return object[] - */ - final public function instantiateMany(int $number, $attributes = []): array - { - return \array_map(fn() => $this->instantiate($attributes), \array_fill(0, $number, null)); - } - /** * @param array|callable $attributes * * @return Proxy|object */ - final public function persist($attributes = [], ?bool $proxy = null): object + final public function create($attributes = []): Proxy { - $object = $this->doInstantiate($attributes, true); + $proxy = new Proxy($this->instantiate($attributes)); + + if (!$this->persist) { + return $proxy; + } - PersistenceManager::persist($object, false); + $proxy->save()->disableAutoRefresh(); foreach ($this->afterPersist as $callback) { - $callback($object, $attributes, PersistenceManager::objectManagerFor($object)); + $proxy->executeCallback($callback, $attributes); } - return ($proxy ?? self::$proxyByDefault) ? PersistenceManager::proxy($object) : $object; + return $proxy->enableAutoRefresh(); } /** @@ -81,9 +69,22 @@ final public function persist($attributes = [], ?bool $proxy = null): object * * @return Proxy[]|object[] */ - final public function persistMany(int $number, $attributes = [], ?bool $proxy = null): array + final public function createMany(int $number, $attributes = []): array + { + return \array_map( + function() use ($attributes) { + return $this->create($attributes); + }, + \array_fill(0, $number, null) + ); + } + + public function withoutPersisting(): self { - return \array_map(fn() => $this->persist($attributes, $proxy), \array_fill(0, $number, null)); + $cloned = clone $this; + $cloned->persist = false; + + return $cloned; } /** @@ -133,7 +134,7 @@ final public function afterPersist(callable $callback): self /** * @param callable $instantiator (array $attributes, string $class): object */ - final public function instantiator(callable $instantiator): self + final public function instantiateWith(callable $instantiator): self { $cloned = clone $this; $cloned->instantiator = $instantiator; @@ -141,27 +142,29 @@ final public function instantiator(callable $instantiator): self return $cloned; } - final public static function proxyByDefault(bool $value): void + /** + * @internal + */ + final public static function boot(Configuration $configuration): void { - self::$proxyByDefault = $value; + self::$configuration = $configuration; } /** - * @param callable $instantiator (array $attributes, string $class): object + * @internal */ - final public static function registerDefaultInstantiator(callable $instantiator): void + final public static function configuration(): Configuration { - self::$defaultInstantiator = $instantiator; - } + if (!self::$configuration) { + throw new \RuntimeException('Foundry is not yet booted, is the ZenstruckFoundryBundle installed/configured?'); + } - final public static function registerFaker(Faker\Generator $faker): void - { - self::$faker = $faker; + return self::$configuration; } final public static function faker(): Faker\Generator { - return self::$faker ?: self::$faker = Faker\Factory::create(); + return self::configuration()->faker(); } /** @@ -175,7 +178,7 @@ private static function normalizeAttributes($attributes): array /** * @param array|callable $attributes */ - private function doInstantiate($attributes, bool $persisting): object + private function instantiate($attributes): object { // merge the factory attribute set with the passed attributes $attributeSet = \array_merge($this->attributeSet, [$attributes]); @@ -192,10 +195,15 @@ private function doInstantiate($attributes, bool $persisting): object } // filter each attribute to convert proxies and factories to objects - $attributes = \array_map(fn($value) => self::filterNormalizedProperty($value, $persisting), $attributes); + $attributes = \array_map( + function($value) { + return $this->normalizeAttribute($value); + }, + $attributes + ); // instantiate the object with the users instantiator or if not set, the default instantiator - $object = ($this->instantiator ?? self::defaultInstantiator())($attributes, $this->class); + $object = ($this->instantiator ?? self::configuration()->instantiator())($attributes, $this->class); foreach ($this->afterInstantiate as $callback) { $callback($object, $attributes); @@ -204,26 +212,36 @@ private function doInstantiate($attributes, bool $persisting): object return $object; } - private static function defaultInstantiator(): callable - { - return self::$defaultInstantiator ?: self::$defaultInstantiator = Instantiator::default(); - } - /** * @param mixed $value * * @return mixed */ - private static function filterNormalizedProperty($value, bool $persist) + private function normalizeAttribute($value) { if ($value instanceof Proxy) { return $value->object(); } + if (\is_array($value)) { + // possible OneToMany/ManyToMany relationship + return \array_map( + function($value) { + return $this->normalizeAttribute($value); + }, + $value + ); + } + if (!$value instanceof self) { return $value; } - return $persist ? $value->persist([], false) : $value->instantiate(); + if (!$this->persist) { + // ensure attribute Factory's are also not persisted + $value = $value->withoutPersisting(); + } + + return $value->create()->object(); } } diff --git a/src/Instantiator.php b/src/Instantiator.php index e08be8e8e..5eb3d6dc3 100644 --- a/src/Instantiator.php +++ b/src/Instantiator.php @@ -2,162 +2,157 @@ namespace Zenstruck\Foundry; +use Symfony\Component\PropertyAccess\Exception\ExceptionInterface as PropertyAccessException; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessor; + /** * @author Kevin Bond */ final class Instantiator { - private const MODE_CONSTRUCTOR_AND_PROPERTIES = 1; - private const MODE_ONLY_CONSTRUCTOR = 2; - private const MODE_ONLY_PROPERTIES = 3; + /** @var PropertyAccessor|null */ + private static $propertyAccessor; - private int $mode; - private bool $strict = false; + /** @var bool */ + private $withoutConstructor = false; - private function __construct(int $mode) - { - $this->mode = $mode; - } + /** @var bool */ + private $allowExtraAttributes = false; + + /** @var bool */ + private $alwaysForceProperties = false; public function __invoke(array $attributes, string $class): object { $object = $this->instantiate($class, $attributes); - if (self::MODE_ONLY_CONSTRUCTOR === $this->mode) { - return $object; - } + foreach ($attributes as $attribute => $value) { + if (0 === \mb_strpos($attribute, 'optional:')) { + continue; + } + + if ($this->alwaysForceProperties) { + try { + self::forceSet($object, $attribute, $value); + } catch (\InvalidArgumentException $e) { + if (!$this->allowExtraAttributes) { + throw $e; + } + } + + continue; + } + + if (0 === \mb_strpos($attribute, 'force:')) { + self::forceSet($object, \mb_substr($attribute, 6), $value); + + continue; + } - foreach ($attributes as $name => $value) { - $this->forceSet($object, $name, $value); + try { + self::propertyAccessor()->setValue($object, $attribute, $value); + } catch (PropertyAccessException $e) { + // see if attribute was snake/kebab cased + try { + self::propertyAccessor()->setValue($object, self::camel($attribute), $value); + } catch (PropertyAccessException $e) { + if (!$this->allowExtraAttributes) { + throw new \InvalidArgumentException(\sprintf('Cannot set attribute "%s" for object "%s" (not public and no setter).', $attribute, $class), 0, $e); + } + } + } } return $object; } /** - * This mode instantiates the object with the given attributes as constructor arguments, then - * sets the remaining attributes to properties (public and private). + * Instantiate objects without calling the constructor. */ - public static function default(): self + public function withoutConstructor(): self { - return new self(self::MODE_CONSTRUCTOR_AND_PROPERTIES); - } + $this->withoutConstructor = true; - /** - * This mode only instantiates the object with the given attributes as constructor arguments. - */ - public static function onlyConstructor(): self - { - return new self(self::MODE_ONLY_CONSTRUCTOR); + return $this; } /** - * This mode instantiates the object without calling the constructor, then sets the attributes to - * properties (public and private). + * Ignore attributes that can't be set to object. */ - public static function withoutConstructor(): self + public function allowExtraAttributes(): self { - return new self(self::MODE_ONLY_PROPERTIES); + $this->allowExtraAttributes = true; + + return $this; } /** - * Throws \InvalidArgumentException for attributes passed that don't exist. + * Always force properties, never use setters (still uses constructor unless disabled). */ - public function strict(): self + public function alwaysForceProperties(): self { - $this->strict = true; + $this->alwaysForceProperties = true; return $this; } - public function forceSet(object $object, string $property, $value): void + /** + * @param mixed $value + * + * @throws \InvalidArgumentException if property does not exist for $object + */ + public static function forceSet(object $object, string $property, $value): void { - $property = $this->propertyForAttributeName($object, $property); - - if (!$property) { - return; - } - - $property->setValue($object, $value); + self::accessibleProperty($object, $property)->setValue($object, $value); } /** - * @return mixed|null - * - * @throws \InvalidArgumentException if $strict = true + * @return mixed */ - public function forceGet(object $object, string $property) + public static function forceGet(object $object, string $property) { - $property = $this->propertyForAttributeName($object, $property); + return self::accessibleProperty($object, $property)->getValue($object); + } - return $property ? $property->getValue($object) : null; + private static function propertyAccessor(): PropertyAccessor + { + return self::$propertyAccessor ?: self::$propertyAccessor = PropertyAccess::createPropertyAccessor(); } - /** - * Check if property exists for passed $name - if not, try camel-casing the name. - */ - private function propertyForAttributeName(object $object, string $name): ?\ReflectionProperty + private static function accessibleProperty(object $object, string $name): \ReflectionProperty { $class = new \ReflectionClass($object); // try fetching first by exact name, if not found, try camel-case - if (!$property = self::getReflectionProperty($class, $name)) { - $property = self::getReflectionProperty($class, self::camel($name)); + if (!$property = self::reflectionProperty($class, $name)) { + $property = self::reflectionProperty($class, self::camel($name)); } - if (!$property && $this->strict) { + if (!$property) { throw new \InvalidArgumentException(\sprintf('Class "%s" does not have property "%s".', $class->getName(), $name)); } - if ($property && !$property->isPublic()) { + if (!$property->isPublic()) { $property->setAccessible(true); } return $property; } - private static function getReflectionProperty(\ReflectionClass $class, string $name): ?\ReflectionProperty + private static function reflectionProperty(\ReflectionClass $class, string $name): ?\ReflectionProperty { try { return $class->getProperty($name); } catch (\ReflectionException $e) { if ($class = $class->getParentClass()) { - return self::getReflectionProperty($class, $name); + return self::reflectionProperty($class, $name); } } return null; } - private function instantiate(string $class, array &$attributes): object - { - $class = new \ReflectionClass($class); - $constructor = $class->getConstructor(); - - if (self::MODE_ONLY_PROPERTIES === $this->mode || !$constructor || !$constructor->isPublic()) { - return $class->newInstanceWithoutConstructor(); - } - - $arguments = []; - - foreach ($constructor->getParameters() as $parameter) { - $name = self::attributeNameForParameter($parameter, $attributes); - - if (\array_key_exists($name, $attributes)) { - $arguments[] = $attributes[$name]; - } elseif ($parameter->isDefaultValueAvailable()) { - $arguments[] = $parameter->getDefaultValue(); - } else { - throw new \InvalidArgumentException(\sprintf('Missing constructor argument "%s" for "%s".', $parameter->getName(), $class->getName())); - } - - // unset attribute so it isn't used when setting object properties - unset($attributes[$name]); - } - - return $class->newInstance(...$arguments); - } - /** * Check if parameter value was passed as the exact name - if not, try snake-cased, then kebab-cased versions. */ @@ -217,4 +212,33 @@ private static function snake(string $string): string return \mb_strtolower(\preg_replace(['/(\p{Lu}+)(\p{Lu}\p{Ll})/u', '/([\p{Ll}0-9])(\p{Lu})/u'], '\1_\2', $string), 'UTF-8'); } + + private function instantiate(string $class, array &$attributes): object + { + $class = new \ReflectionClass($class); + $constructor = $class->getConstructor(); + + if ($this->withoutConstructor || !$constructor || !$constructor->isPublic()) { + return $class->newInstanceWithoutConstructor(); + } + + $arguments = []; + + foreach ($constructor->getParameters() as $parameter) { + $name = self::attributeNameForParameter($parameter, $attributes); + + if (\array_key_exists($name, $attributes)) { + $arguments[] = $attributes[$name]; + } elseif ($parameter->isDefaultValueAvailable()) { + $arguments[] = $parameter->getDefaultValue(); + } else { + throw new \InvalidArgumentException(\sprintf('Missing constructor argument "%s" for "%s".', $parameter->getName(), $class->getName())); + } + + // unset attribute so it isn't used when setting object properties + unset($attributes[$name]); + } + + return $class->newInstance(...$arguments); + } } diff --git a/src/ModelFactory.php b/src/ModelFactory.php index 914bd725d..987dcb60f 100644 --- a/src/ModelFactory.php +++ b/src/ModelFactory.php @@ -2,8 +2,6 @@ namespace Zenstruck\Foundry; -use Doctrine\Persistence\ObjectRepository; - /** * @author Kevin Bond */ @@ -20,7 +18,6 @@ private function __construct() */ final public static function new($defaultAttributes = [], string ...$states): self { - // todo - is this too magical? if (\is_string($defaultAttributes)) { $states = \array_merge([$defaultAttributes], $states); $defaultAttributes = []; @@ -33,6 +30,10 @@ final public static function new($defaultAttributes = [], string ...$states): se ->initialize() ; + if (!$factory instanceof static) { + throw new \TypeError(\sprintf('"%1$s::initialize()" must return an instance of "%1$s".', static::class)); + } + foreach ($states as $state) { $factory = $factory->{$state}(); } @@ -41,78 +42,55 @@ final public static function new($defaultAttributes = [], string ...$states): se } /** - * Instantiate and persist. - * - * @param array|callable $attributes + * Try and find existing object for the given $attributes. If not found, + * instantiate and persist. * * @return Proxy|object */ - final public static function create($attributes = [], ?bool $proxy = null): object + final public static function findOrCreate(array $attributes): Proxy { - return static::new()->persist($attributes, $proxy); - } + if ($found = static::repository()->find($attributes)) { + return $found; + } - /** - * Instantiate many and persist. - * - * @param array|callable $attributes - * - * @return Proxy[]|object[] - */ - final public static function createMany(int $number, $attributes = [], ?bool $proxy = null): array - { - return static::new()->persistMany($number, $attributes, $proxy); + return static::new()->create($attributes); } /** - * Instantiate without persisting. - * - * @param array|callable $attributes + * @see RepositoryProxy::random() */ - final public static function make($attributes = []): object + final public static function random(): Proxy { - return static::new()->instantiate($attributes); + return static::repository()->random(); } /** - * Instantiate many without persisting. - * - * @param array|callable $attributes - * - * @return object[] + * @see RepositoryProxy::randomSet() */ - final public static function makeMany(int $number, $attributes = []): array + final public static function randomSet(int $number): array { - return static::new()->instantiateMany($number, $attributes); + return static::repository()->randomSet($number); } /** - * Try and find existing object for the given $attributes. If not found, - * instantiate and persist. - * - * @return Proxy|object + * @see RepositoryProxy::randomRange() */ - final public static function findOrCreate(array $attributes): object + final public static function randomRange(int $min, int $max): array { - if ($found = self::repository(true)->find($attributes)) { - return $found; - } - - return self::create($attributes); + return static::repository()->randomRange($min, $max); } - /** - * @return RepositoryProxy|ObjectRepository - */ - final public static function repository(bool $proxy = true): ObjectRepository + final public static function repository(): RepositoryProxy { - return PersistenceManager::repositoryFor(static::getClass(), $proxy); + return static::configuration()->repositoryFor(static::getClass()); } /** * Override to add default instantiator and default afterInstantiate/afterPersist events. + * + * @return static */ - protected function initialize(): self + protected function initialize() { return $this; } diff --git a/src/PersistenceManager.php b/src/PersistenceManager.php deleted file mode 100644 index e4ac741ba..000000000 --- a/src/PersistenceManager.php +++ /dev/null @@ -1,84 +0,0 @@ - - */ -final class PersistenceManager -{ - private static ?ManagerRegistry $managerRegistry = null; - - /** - * @param object|string $objectOrClass - * - * @return RepositoryProxy|ObjectRepository - */ - public static function repositoryFor($objectOrClass, bool $proxy = true): ObjectRepository - { - if ($objectOrClass instanceof Proxy) { - $objectOrClass = $objectOrClass->object(); - } - - if (!\is_string($objectOrClass)) { - $objectOrClass = \get_class($objectOrClass); - } - - $repository = self::managerRegistry()->getRepository($objectOrClass); - - return $proxy ? new RepositoryProxy($repository) : $repository; - } - - /** - * @return Proxy|object - */ - public static function persist(object $object, bool $proxy = true): object - { - $objectManager = self::objectManagerFor($object); - $objectManager->persist($object); - $objectManager->flush(); - - return $proxy ? self::proxy($object) : $object; - } - - public static function proxy(object $object): Proxy - { - if ($object instanceof Proxy) { - return $object; - } - - return new Proxy($object); - } - - public static function register(ManagerRegistry $managerRegistry): void - { - self::$managerRegistry = $managerRegistry; - } - - /** - * @param object|string $objectOrClass - */ - public static function objectManagerFor($objectOrClass): ObjectManager - { - $class = \is_string($objectOrClass) ? $objectOrClass : \get_class($objectOrClass); - - if (!$objectManager = self::managerRegistry()->getManagerForClass($class)) { - throw new \RuntimeException(\sprintf('No object manager registered for "%s".', $class)); - } - - return $objectManager; - } - - private static function managerRegistry(): ManagerRegistry - { - if (null === self::$managerRegistry) { - throw new \RuntimeException('ManagerRegistry not registered...'); // todo improve - } - - return self::$managerRegistry; - } -} diff --git a/src/Proxy.php b/src/Proxy.php index d1674e58a..3e36a6236 100644 --- a/src/Proxy.php +++ b/src/Proxy.php @@ -3,7 +3,6 @@ namespace Zenstruck\Foundry; use Doctrine\Persistence\ObjectManager; -use Doctrine\Persistence\ObjectRepository; use PHPUnit\Framework\Assert; /** @@ -11,12 +10,17 @@ */ final class Proxy { - private static bool $autoRefreshByDefault = true; - private static ?Instantiator $instantiator = null; + /** @var object */ + private $object; - private object $object; - private string $class; - private ?bool $autoRefresh = null; + /** @var string */ + private $class; + + /** @var bool */ + private $autoRefresh = false; + + /** @var bool */ + private $persisted = false; public function __construct(object $object) { @@ -52,15 +56,32 @@ public function __isset(string $name): bool public function __toString(): string { if (!\method_exists($this->object, '__toString')) { + if (\PHP_VERSION_ID < 70400) { + return '(no __toString)'; + } + throw new \RuntimeException(\sprintf('Proxied object "%s" cannot be converted to a string.', $this->class)); } return $this->object()->__toString(); } + public static function persisted(object $object): self + { + $proxy = new self($object); + $proxy->persisted = $proxy->autoRefresh = true; + + return $proxy; + } + + public function isPersisted(): bool + { + return $this->persisted; + } + public function object(): object { - if ($this->autoRefresh ?? self::$autoRefreshByDefault) { + if ($this->autoRefresh && $this->persisted) { $this->refresh(); } @@ -71,6 +92,7 @@ public function save(): self { $this->objectManager()->persist($this->object); $this->objectManager()->flush(); + $this->autoRefresh = $this->persisted = true; return $this; } @@ -79,12 +101,17 @@ public function remove(): self { $this->objectManager()->remove($this->object); $this->objectManager()->flush(); + $this->autoRefresh = $this->persisted = false; return $this; } public function refresh(): self { + if (!$this->persisted) { + throw new \RuntimeException(\sprintf('Cannot refresh unpersisted object (%s).', $this->class)); + } + if ($this->objectManager()->contains($this->object)) { $this->objectManager()->refresh($this->object); @@ -113,7 +140,7 @@ public function forceSetAll(array $properties): self $object = $this->object(); foreach ($properties as $property => $value) { - self::instantiator()->forceSet($object, $property, $value); + Instantiator::forceSet($object, $property, $value); } return $this; @@ -124,55 +151,70 @@ public function forceSetAll(array $properties): self */ public function forceGet(string $property) { - return self::instantiator()->forceGet($this->object(), $property); + return Instantiator::forceGet($this->object(), $property); } - /** - * @return RepositoryProxy|ObjectRepository - */ - public function repository(bool $proxy = true): ObjectRepository + public function repository(): RepositoryProxy { - return PersistenceManager::repositoryFor($this->class, $proxy); + return Factory::configuration()->repositoryFor($this->class); } - public function withAutoRefresh(): self + public function enableAutoRefresh(): self { + if (!$this->persisted) { + throw new \RuntimeException(\sprintf('Cannot enable auto-refresh on unpersisted object (%s).', $this->class)); + } + $this->autoRefresh = true; return $this; } - public function withoutAutoRefresh(): self + public function disableAutoRefresh(): self { $this->autoRefresh = false; return $this; } - public function assertPersisted(): self + public function withoutAutoRefresh(callable $callback): self { - // todo improve message - Assert::assertNotNull($this->fetchObject(), 'The object is not persisted.'); + $this->disableAutoRefresh(); - return $this; + $this->executeCallback($callback); + + return $this->isPersisted() ? $this->enableAutoRefresh() : $this; } - public function assertNotPersisted(): self + public function assertPersisted(string $message = 'The object is not persisted.'): self { - // todo improve message - Assert::assertNull($this->fetchObject(), 'The object is persisted but it should not be.'); + Assert::assertNotNull($this->fetchObject(), $message); return $this; } - public static function autoRefreshByDefault(bool $value): void + public function assertNotPersisted(string $message = 'The object is persisted but it should not be.'): self { - self::$autoRefreshByDefault = $value; + Assert::assertNull($this->fetchObject(), $message); + + return $this; } /** - * Todo - move to RepositoryProxy? + * @internal */ + public function executeCallback(callable $callback, ...$arguments): void + { + $object = $this; + $parameters = (new \ReflectionFunction($callback))->getParameters(); + + if (isset($parameters[0]) && $parameters[0]->getType() && $this->class === $parameters[0]->getType()->getName()) { + $object = $object->object(); + } + + $callback($object, ...$arguments); + } + private function fetchObject(): ?object { $id = $this->objectManager()->getClassMetadata($this->class)->getIdentifierValues($this->object); @@ -182,11 +224,6 @@ private function fetchObject(): ?object private function objectManager(): ObjectManager { - return PersistenceManager::objectManagerFor($this->class); - } - - private static function instantiator(): Instantiator - { - return self::$instantiator ?: self::$instantiator = Instantiator::default()->strict(); + return Factory::configuration()->objectManagerFor($this->class); } } diff --git a/src/RepositoryProxy.php b/src/RepositoryProxy.php index 82d68bc9f..3d593fcd7 100644 --- a/src/RepositoryProxy.php +++ b/src/RepositoryProxy.php @@ -14,7 +14,8 @@ */ final class RepositoryProxy implements ObjectRepository { - private ObjectRepository $repository; + /** @var ObjectRepository */ + private $repository; public function __construct(ObjectRepository $repository) { @@ -35,47 +36,42 @@ public function getCount(): int return \count($this->findAll()); } - public function assertEmpty(): self + public function assertEmpty(string $message = ''): self { - return $this->assertCount(0); + return $this->assertCount(0, $message); } - public function assertCount(int $expectedCount): self + public function assertCount(int $expectedCount, string $message = ''): self { - // todo add message - Assert::assertSame($expectedCount, $this->getCount()); + Assert::assertSame($expectedCount, $this->getCount(), $message); return $this; } - public function assertCountGreaterThan(int $expected): self + public function assertCountGreaterThan(int $expected, string $message = ''): self { - // todo add message - Assert::assertGreaterThan($expected, $this->getCount()); + Assert::assertGreaterThan($expected, $this->getCount(), $message); return $this; } - public function assertCountGreaterThanOrEqual(int $expected): self + public function assertCountGreaterThanOrEqual(int $expected, string $message = ''): self { - // todo add message - Assert::assertGreaterThanOrEqual($expected, $this->getCount()); + Assert::assertGreaterThanOrEqual($expected, $this->getCount(), $message); return $this; } - public function assertCountLessThan(int $expected): self + public function assertCountLessThan(int $expected, string $message = ''): self { - // todo add message - Assert::assertLessThan($expected, $this->getCount()); + Assert::assertLessThan($expected, $this->getCount(), $message); return $this; } - public function assertCountLessThanOrEqual(int $expected): self + public function assertCountLessThanOrEqual(int $expected, string $message = ''): self { - // todo add message - Assert::assertLessThanOrEqual($expected, $this->getCount()); + Assert::assertLessThanOrEqual($expected, $this->getCount(), $message); return $this; } @@ -83,10 +79,9 @@ public function assertCountLessThanOrEqual(int $expected): self /** * @param object|array|mixed $criteria */ - public function assertExists($criteria): self + public function assertExists($criteria, string $message = ''): self { - // todo add message - Assert::assertNotNull($this->find($criteria)); + Assert::assertNotNull($this->find($criteria), $message); return $this; } @@ -94,10 +89,9 @@ public function assertExists($criteria): self /** * @param object|array|mixed $criteria */ - public function assertNotExists($criteria): self + public function assertNotExists($criteria, string $message = ''): self { - // todo add message - Assert::assertNull($this->find($criteria)); + Assert::assertNull($this->find($criteria), $message); return $this; } @@ -105,7 +99,7 @@ public function assertNotExists($criteria): self /** * @return Proxy|object|null */ - public function first(): ?object + public function first(): ?Proxy { return $this->findOneBy([]); } @@ -115,7 +109,7 @@ public function first(): ?object */ public function truncate(): void { - $om = PersistenceManager::objectManagerFor($this->getClassName()); + $om = Factory::configuration()->objectManagerFor($this->getClassName()); if (!$om instanceof EntityManagerInterface) { throw new \RuntimeException('This operation is only available when using doctrine/orm'); @@ -124,12 +118,76 @@ public function truncate(): void $om->createQuery("DELETE {$this->getClassName()} e")->execute(); } + /** + * Fetch one random object. + * + * @return Proxy|object + * + * @throws \RuntimeException if no objects are persisted + */ + public function random(): Proxy + { + return $this->randomSet(1)[0]; + } + + /** + * Fetch a random set of objects. + * + * @param int $number The number of objects to return + * + * @return Proxy[]|object[] + * + * @throws \RuntimeException if not enough persisted objects to satisfy the number requested + * @throws \InvalidArgumentException if number is less than zero + */ + public function randomSet(int $number): array + { + if ($number < 0) { + throw new \InvalidArgumentException(\sprintf('$number must be positive (%d given).', $number)); + } + + return $this->randomRange($number, $number); + } + + /** + * Fetch a random range of objects. + * + * @param int $min The minimum number of objects to return + * @param int $max The maximum number of objects to return + * + * @return Proxy[]|object[] + * + * @throws \RuntimeException if not enough persisted objects to satisfy the max + * @throws \InvalidArgumentException if min is less than zero + * @throws \InvalidArgumentException if max is less than min + */ + public function randomRange(int $min, int $max): array + { + if ($min < 0) { + throw new \InvalidArgumentException(\sprintf('$min must be positive (%d given).', $min)); + } + + if ($max < $min) { + throw new \InvalidArgumentException(\sprintf('$max (%d) cannot be less than $min (%d).', $max, $min)); + } + + $all = \array_values($this->findAll()); + + \shuffle($all); + + if (\count($all) < $max) { + throw new \RuntimeException(\sprintf('At least %d "%s" object(s) must have been persisted (%d persisted).', $max, $this->getClassName(), \count($all))); + } + + return \array_slice($all, 0, \random_int($min, $max)); + } + /** * @param object|array|mixed $criteria * * @return Proxy|object|null */ - public function find($criteria): ?object + public function find($criteria): ?Proxy { if ($criteria instanceof Proxy) { $criteria = $criteria->object(); @@ -161,7 +219,7 @@ public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $ /** * @return Proxy|object|null */ - public function findOneBy(array $criteria): ?object + public function findOneBy(array $criteria): ?Proxy { return $this->proxyResult($this->repository->findOneBy(self::normalizeCriteria($criteria))); } @@ -179,7 +237,7 @@ public function getClassName(): string private function proxyResult($result) { if (\is_object($result) && $this->getClassName() === \get_class($result)) { - return PersistenceManager::proxy($result); + return Proxy::persisted($result); } if (\is_array($result)) { diff --git a/src/Story.php b/src/Story.php index d63e11a99..cd22dd0a8 100644 --- a/src/Story.php +++ b/src/Story.php @@ -8,11 +8,7 @@ abstract class Story { /** @var array */ - private array $objects = []; - - private function __construct() - { - } + private $objects = []; final public function __call(string $method, array $arguments) { @@ -26,27 +22,27 @@ public static function __callStatic($name, $arguments) final public static function load(): self { - if (StoryManager::has(static::class)) { - return StoryManager::get(static::class); - } - - $story = new static(); - $story->build(); - - StoryManager::set($story); - - return $story; + return Factory::configuration()->stories()->load(static::class); } final public function add(string $name, object $object): self { // ensure factories are persisted if ($object instanceof Factory) { - $object = $object->persist(); + $object = $object->create(); + } + + // ensure objects are proxied + if (!$object instanceof Proxy) { + $object = new Proxy($object); + } + + // ensure proxies are persisted + if (!$object->isPersisted()) { + $object->save(); } - // ensure all objects are wrapped in a auto-refreshing proxy - $this->objects[$name] = PersistenceManager::proxy($object)->withAutoRefresh(); + $this->objects[$name] = $object; return $this; } @@ -54,11 +50,11 @@ final public function add(string $name, object $object): self final public function get(string $name): Proxy { if (!\array_key_exists($name, $this->objects)) { - throw new \InvalidArgumentException('explain that object was not registered'); // todo + throw new \InvalidArgumentException(\sprintf('"%s" was not registered. Did you forget to call "%s::add()"?', $name, static::class)); } return $this->objects[$name]; } - abstract protected function build(): void; + abstract public function build(): void; } diff --git a/src/StoryManager.php b/src/StoryManager.php index c4c9bbb8f..d8b9c9f78 100644 --- a/src/StoryManager.php +++ b/src/StoryManager.php @@ -10,28 +10,36 @@ final class StoryManager { /** @var array */ - private static array $globalInstances = []; + private static $globalInstances = []; /** @var array */ - private static array $instances = []; + private static $instances = []; - public static function has(string $story): bool + /** @var Story[] */ + private $stories; + + /** + * @param Story[] $stories + */ + public function __construct(iterable $stories) { - return \array_key_exists($story, self::$globalInstances) || \array_key_exists($story, self::$instances); + $this->stories = $stories; } - public static function get(string $story): Story + public function load(string $class): Story { - if (\array_key_exists($story, self::$globalInstances)) { - return self::$globalInstances[$story]; + if (\array_key_exists($class, self::$globalInstances)) { + return self::$globalInstances[$class]; } - return self::$instances[$story]; - } + if (\array_key_exists($class, self::$instances)) { + return self::$instances[$class]; + } - public static function set(Story $story): void - { - self::$instances[\get_class($story)] = $story; + $story = $this->getOrCreateStory($class); + $story->build(); + + return self::$instances[$class] = $story; } public static function setGlobalState(): void @@ -49,4 +57,15 @@ public static function globalReset(): void { self::$globalInstances = self::$instances = []; } + + private function getOrCreateStory(string $class): Story + { + foreach ($this->stories as $story) { + if ($class === \get_class($story)) { + return $story; + } + } + + return new $class(); + } } diff --git a/src/Test/DatabaseResetter.php b/src/Test/DatabaseResetter.php index 278e4a48f..9f8443694 100644 --- a/src/Test/DatabaseResetter.php +++ b/src/Test/DatabaseResetter.php @@ -16,7 +16,8 @@ */ final class DatabaseResetter { - private static bool $hasBeenReset = false; + /** @var bool */ + private static $hasBeenReset = false; public static function hasBeenReset(): bool { @@ -71,7 +72,8 @@ private static function createSchema(Application $application, ManagerRegistry $ ]); } - GlobalState::flush($registry); + TestState::bootFromContainer($application->getKernel()->getContainer()); + TestState::flushGlobalState(); } private static function dropSchema(Application $application, ManagerRegistry $registry): void diff --git a/src/Test/Factories.php b/src/Test/Factories.php index 5a98751cc..9e45118ad 100644 --- a/src/Test/Factories.php +++ b/src/Test/Factories.php @@ -4,7 +4,6 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Foundry\Factory; -use Zenstruck\Foundry\PersistenceManager; use Zenstruck\Foundry\StoryManager; /** @@ -24,13 +23,21 @@ public static function _setUpFactories(): void throw new \RuntimeException(\sprintf('The "%s" trait can only be used on TestCases that extend "%s".', __TRAIT__, KernelTestCase::class)); } - PersistenceManager::register(new LazyManagerRegistry(static function() { - if (!static::$booted) { - static::bootKernel(); - } + if (!static::$booted) { + static::bootKernel(); + } + + TestState::bootFromContainer(static::$kernel->getContainer())->setManagerRegistry( + new LazyManagerRegistry(static function() { + if (!static::$booted) { + static::bootKernel(); + } + + return static::$kernel->getContainer()->get('doctrine'); + }) + ); - return static::$kernel->getContainer()->get('doctrine'); - })); + self::ensureKernelShutdown(); } /** diff --git a/src/Test/GlobalState.php b/src/Test/GlobalState.php deleted file mode 100644 index cd180f974..000000000 --- a/src/Test/GlobalState.php +++ /dev/null @@ -1,36 +0,0 @@ - - */ -final class GlobalState -{ - private static array $callbacks = []; - - public static function add(callable $callback): void - { - self::$callbacks[] = $callback; - } - - /** - * @internal - */ - public static function flush(ManagerRegistry $registry): void - { - StoryManager::globalReset(); - - PersistenceManager::register($registry); - - foreach (self::$callbacks as $callback) { - $callback(); - } - - StoryManager::setGlobalState(); - } -} diff --git a/src/Test/TestState.php b/src/Test/TestState.php new file mode 100644 index 000000000..600554f7f --- /dev/null +++ b/src/Test/TestState.php @@ -0,0 +1,97 @@ + + */ +final class TestState +{ + /** @var callable|null */ + private static $instantiator; + + /** @var Faker\Generator|null */ + private static $faker; + + /** @var bool */ + private static $useBundle = true; + + /** @var callable[] */ + private static $globalStates = []; + + public static function setInstantiator(callable $instantiator): void + { + self::$instantiator = $instantiator; + } + + public static function setFaker(Faker\Generator $faker): void + { + self::$faker = $faker; + } + + public static function withoutBundle(): void + { + self::$useBundle = false; + } + + public static function addGlobalState(callable $callback): void + { + self::$globalStates[] = $callback; + } + + public static function bootFactory(Configuration $configuration): Configuration + { + if (self::$instantiator) { + $configuration->setInstantiator(self::$instantiator); + } + + if (self::$faker) { + $configuration->setFaker(self::$faker); + } + + Factory::boot($configuration); + + return $configuration; + } + + /** + * @internal + */ + public static function bootFromContainer(ContainerInterface $container): Configuration + { + if (self::$useBundle) { + try { + return self::bootFactory($container->get(Configuration::class)); + } catch (NotFoundExceptionInterface $e) { + throw new \LogicException('Could not boot Foundry, is the ZenstruckFoundryBundle installed/configured?', 0, $e); + } + } + + try { + return self::bootFactory(new Configuration($container->get('doctrine'), new StoryManager([]))); + } catch (NotFoundExceptionInterface $e) { + throw new \LogicException('Could not boot Foundry, is the DoctrineBundle installed/configured?', 0, $e); + } + } + + /** + * @internal + */ + public static function flushGlobalState(): void + { + StoryManager::globalReset(); + + foreach (self::$globalStates as $callback) { + $callback(); + } + + StoryManager::setGlobalState(); + } +} diff --git a/src/functions.php b/src/functions.php index 015a401a2..fe54456ae 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,7 +2,6 @@ namespace Zenstruck\Foundry; -use Doctrine\Persistence\ObjectRepository; use Faker; /** @@ -14,49 +13,51 @@ function factory(string $class, $defaultAttributes = []): Factory } /** - * @see Factory::persist() + * @see Factory::create() * * @return Proxy|object */ -function persist(string $class, $attributes = [], ?bool $proxy = null): object +function create(string $class, $attributes = []): Proxy { - return factory($class)->persist($attributes, $proxy); + return factory($class)->create($attributes); } /** - * @see Factory::persistMany() + * @see Factory::createMany() * * @return Proxy[]|object[] */ -function persist_many(int $number, string $class, $attributes = [], ?bool $proxy = null): array +function create_many(int $number, string $class, $attributes = []): array { - return factory($class)->persistMany($number, $attributes, $proxy); + return factory($class)->createMany($number, $attributes); } /** - * @see Factory::instantiate() + * Instantiate object without persisting. + * + * @return Proxy|object "unpersisted" Proxy wrapping the instantiated object */ -function instantiate(string $class, $attributes = []): object +function instantiate(string $class, $attributes = []): Proxy { - return factory($class)->instantiate($attributes); + return factory($class)->withoutPersisting()->create($attributes); } /** - * @see Factory::instantiateMany() + * Instantiate X objects without persisting. + * + * @return Proxy[]|object[] "unpersisted" Proxy's wrapping the instantiated objects */ function instantiate_many(int $number, string $class, $attributes = []): array { - return factory($class)->instantiateMany($number, $attributes); + return factory($class)->withoutPersisting()->createMany($number, $attributes); } /** - * @see PersistenceManager::repositoryFor() - * - * @return RepositoryProxy|ObjectRepository + * @see Configuration::repositoryFor() */ -function repository($objectOrClass, bool $proxy = true): ObjectRepository +function repository($objectOrClass): RepositoryProxy { - return PersistenceManager::repositoryFor($objectOrClass, $proxy); + return Factory::configuration()->repositoryFor($objectOrClass); } /** diff --git a/tests/Fixtures/DAMADoctrineTestKernel.php b/tests/Fixtures/DAMADoctrineTestKernel.php deleted file mode 100644 index e36aa24c7..000000000 --- a/tests/Fixtures/DAMADoctrineTestKernel.php +++ /dev/null @@ -1,18 +0,0 @@ - - */ -final class DAMADoctrineTestKernel extends Kernel -{ - public function registerBundles(): iterable - { - yield from parent::registerBundles(); - - yield new DAMADoctrineTestBundle(); - } -} diff --git a/tests/Fixtures/Entity/Category.php b/tests/Fixtures/Entity/Category.php index 934f26aec..5d0468dd2 100644 --- a/tests/Fixtures/Entity/Category.php +++ b/tests/Fixtures/Entity/Category.php @@ -2,6 +2,7 @@ namespace Zenstruck\Foundry\Tests\Fixtures\Entity; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; /** @@ -21,8 +22,52 @@ class Category */ private $name; + /** + * @ORM\OneToMany(targetEntity=Post::class, mappedBy="category") + */ + private $posts; + + public function __construct() + { + $this->posts = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } + public function getName(): ?string { return $this->name; } + + public function setName($name) + { + $this->name = $name; + } + + public function getPosts() + { + return $this->posts; + } + + public function addPost(Post $post) + { + if (!$this->posts->contains($post)) { + $this->posts[] = $post; + $post->setCategory($this); + } + } + + public function removePost(Post $post) + { + if ($this->posts->contains($post)) { + $this->posts->removeElement($post); + // set the owning side to null (unless already changed) + if ($post->getCategory() === $this) { + $post->setCategory(null); + } + } + } } diff --git a/tests/Fixtures/Entity/Post.php b/tests/Fixtures/Entity/Post.php index 39100269b..841769fe2 100644 --- a/tests/Fixtures/Entity/Post.php +++ b/tests/Fixtures/Entity/Post.php @@ -2,6 +2,7 @@ namespace Zenstruck\Foundry\Tests\Fixtures\Entity; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; /** @@ -47,17 +48,23 @@ class Post private $publishedAt; /** - * @ORM\ManyToOne(targetEntity=Category::class) + * @ORM\ManyToOne(targetEntity=Category::class, inversedBy="posts") * @ORM\JoinColumn */ private $category; - public function __construct(string $title, string $body, string $shortDescription = null) + /** + * @ORM\ManyToMany(targetEntity=Tag::class, inversedBy="posts") + */ + private $tags; + + public function __construct(string $title, string $body, ?string $shortDescription = null) { $this->title = $title; $this->body = $body; $this->shortDescription = $shortDescription; $this->createdAt = new \DateTime('now'); + $this->tags = new ArrayCollection(); } public function __toString(): string @@ -100,8 +107,37 @@ public function getCategory(): ?Category return $this->category; } + public function setCategory(?Category $category) + { + $this->category = $category; + } + public function isPublished(): bool { return null !== $this->publishedAt; } + + public function setPublishedAt(\DateTime $timestamp) + { + $this->publishedAt = $timestamp; + } + + public function getTags() + { + return $this->tags; + } + + public function addTag(Tag $tag) + { + if (!$this->tags->contains($tag)) { + $this->tags[] = $tag; + } + } + + public function removeTag(Tag $tag) + { + if ($this->tags->contains($tag)) { + $this->tags->removeElement($tag); + } + } } diff --git a/tests/Fixtures/Entity/Tag.php b/tests/Fixtures/Entity/Tag.php index 653961076..9168547c6 100644 --- a/tests/Fixtures/Entity/Tag.php +++ b/tests/Fixtures/Entity/Tag.php @@ -2,6 +2,7 @@ namespace Zenstruck\Foundry\Tests\Fixtures\Entity; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; /** @@ -21,8 +22,44 @@ class Tag */ private $name; + /** + * @ORM\ManyToMany(targetEntity=Post::class, mappedBy="tags") + */ + private $posts; + + public function __construct() + { + $this->posts = new ArrayCollection(); + } + public function getName(): ?string { return $this->name; } + + public function setName($name) + { + $this->name = $name; + } + + public function getPosts() + { + return $this->posts; + } + + public function addPost(Post $post) + { + if (!$this->posts->contains($post)) { + $this->posts[] = $post; + $post->addTag($this); + } + } + + public function removePost(Post $post) + { + if ($this->posts->contains($post)) { + $this->posts->removeElement($post); + $post->removeTag($this); + } + } } diff --git a/tests/Fixtures/Factories/PostFactory.php b/tests/Fixtures/Factories/PostFactory.php index 4041a10ec..7bae34d40 100644 --- a/tests/Fixtures/Factories/PostFactory.php +++ b/tests/Fixtures/Factories/PostFactory.php @@ -8,7 +8,7 @@ /** * @author Kevin Bond */ -final class PostFactory extends ModelFactory +class PostFactory extends ModelFactory { public function published(): self { diff --git a/tests/Fixtures/Factories/PostFactoryWithInvalidInitialize.php b/tests/Fixtures/Factories/PostFactoryWithInvalidInitialize.php new file mode 100644 index 000000000..be5906417 --- /dev/null +++ b/tests/Fixtures/Factories/PostFactoryWithInvalidInitialize.php @@ -0,0 +1,14 @@ + + */ +final class PostFactoryWithInvalidInitialize extends PostFactory +{ + protected function initialize() + { + return PostFactory::new(); + } +} diff --git a/tests/Fixtures/Factories/PostFactoryWithNullInitialize.php b/tests/Fixtures/Factories/PostFactoryWithNullInitialize.php new file mode 100644 index 000000000..74eacc33c --- /dev/null +++ b/tests/Fixtures/Factories/PostFactoryWithNullInitialize.php @@ -0,0 +1,13 @@ + + */ +final class PostFactoryWithNullInitialize extends PostFactory +{ + protected function initialize() + { + } +} diff --git a/tests/Fixtures/Factories/PostFactoryWithValidInitialize.php b/tests/Fixtures/Factories/PostFactoryWithValidInitialize.php new file mode 100644 index 000000000..45284a6d0 --- /dev/null +++ b/tests/Fixtures/Factories/PostFactoryWithValidInitialize.php @@ -0,0 +1,14 @@ + + */ +final class PostFactoryWithValidInitialize extends PostFactory +{ + protected function initialize(): self + { + return $this->published(); + } +} diff --git a/tests/Fixtures/Kernel.php b/tests/Fixtures/Kernel.php index 0f50dbade..0959de976 100644 --- a/tests/Fixtures/Kernel.php +++ b/tests/Fixtures/Kernel.php @@ -2,6 +2,7 @@ namespace Zenstruck\Foundry\Tests\Fixtures; +use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Psr\Log\NullLogger; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; @@ -10,6 +11,8 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel as BaseKernel; use Symfony\Component\Routing\RouteCollectionBuilder; +use Zenstruck\Foundry\Bundle\ZenstruckFoundryBundle; +use Zenstruck\Foundry\Tests\Fixtures\Stories\ServiceStory; /** * @author Kevin Bond @@ -20,10 +23,16 @@ class Kernel extends BaseKernel public function registerBundles(): iterable { - return [ - new FrameworkBundle(), - new DoctrineBundle(), - ]; + yield new FrameworkBundle(); + yield new DoctrineBundle(); + + if (\getenv('USE_FOUNDRY_BUNDLE')) { + yield new ZenstruckFoundryBundle(); + } + + if (\getenv('USE_DAMA_DOCTRINE_TEST_BUNDLE')) { + yield new DAMADoctrineTestBundle(); + } } public function getLogDir(): string @@ -40,6 +49,12 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load { $c->register('logger', NullLogger::class); + $c->register(Service::class); + $c->register(ServiceStory::class) + ->setAutoconfigured(true) + ->setAutowired(true) + ; + $c->loadFromExtension('framework', [ 'secret' => 'S3CRET', 'test' => true, diff --git a/tests/Fixtures/Service.php b/tests/Fixtures/Service.php new file mode 100644 index 000000000..9add3cec2 --- /dev/null +++ b/tests/Fixtures/Service.php @@ -0,0 +1,11 @@ + + */ +final class Service +{ + public $name = 'From Service'; +} diff --git a/tests/Fixtures/Stories/CategoryStory.php b/tests/Fixtures/Stories/CategoryStory.php index abbcaf0e6..ea0ecf367 100644 --- a/tests/Fixtures/Stories/CategoryStory.php +++ b/tests/Fixtures/Stories/CategoryStory.php @@ -10,9 +10,9 @@ */ final class CategoryStory extends Story { - protected function build(): void + public function build(): void { - $this->add('php', CategoryFactory::create(['name' => 'php'])); - $this->add('symfony', CategoryFactory::create(['name' => 'symfony'])); + $this->add('php', CategoryFactory::new()->create(['name' => 'php'])); + $this->add('symfony', CategoryFactory::new()->create(['name' => 'symfony'])); } } diff --git a/tests/Fixtures/Stories/PostStory.php b/tests/Fixtures/Stories/PostStory.php index fb45c57ac..08f369f00 100644 --- a/tests/Fixtures/Stories/PostStory.php +++ b/tests/Fixtures/Stories/PostStory.php @@ -10,24 +10,24 @@ */ final class PostStory extends Story { - protected function build(): void + public function build(): void { - $this->add('postA', PostFactory::create([ + $this->add('postA', PostFactory::new()->create([ 'title' => 'Post A', 'category' => CategoryStory::php(), ])); - $this->add('postB', PostFactory::create([ + $this->add('postB', PostFactory::new()->create([ 'title' => 'Post B', 'category' => CategoryStory::php(), - ], false)); + ])->object()); $this->add('postC', PostFactory::new([ 'title' => 'Post C', 'category' => CategoryStory::symfony(), ])); - PostFactory::create([ + PostFactory::new()->create([ 'title' => 'Post D', 'category' => CategoryStory::php(), ]); diff --git a/tests/Fixtures/Stories/ServiceStory.php b/tests/Fixtures/Stories/ServiceStory.php new file mode 100644 index 000000000..6b0d575f2 --- /dev/null +++ b/tests/Fixtures/Stories/ServiceStory.php @@ -0,0 +1,26 @@ + + */ +final class ServiceStory extends Story +{ + /** @var Service */ + private $service; + + public function __construct(Service $service) + { + $this->service = $service; + } + + public function build(): void + { + $this->add('post', PostFactory::new()->create(['title' => $this->service->name])); + } +} diff --git a/tests/Fixtures/Stories/TagStory.php b/tests/Fixtures/Stories/TagStory.php index 11e060f21..18f433ccb 100644 --- a/tests/Fixtures/Stories/TagStory.php +++ b/tests/Fixtures/Stories/TagStory.php @@ -10,9 +10,9 @@ */ final class TagStory extends Story { - protected function build(): void + public function build(): void { - $this->add('dev', TagFactory::create(['name' => 'dev'])); - $this->add('design', TagFactory::create(['name' => 'design'])); + $this->add('dev', TagFactory::new()->create(['name' => 'dev'])); + $this->add('design', TagFactory::new()->create(['name' => 'design'])); } } diff --git a/tests/Functional/FactoryTest.php b/tests/Functional/FactoryTest.php new file mode 100644 index 000000000..77835834e --- /dev/null +++ b/tests/Functional/FactoryTest.php @@ -0,0 +1,121 @@ + + */ +final class FactoryTest extends FunctionalTestCase +{ + /** + * @test + */ + public function many_to_one_relationship(): void + { + $categoryFactory = factory(Category::class, ['name' => 'foo']); + $category = create(Category::class, ['name' => 'bar']); + $postA = create(Post::class, ['title' => 'title', 'body' => 'body', 'category' => $categoryFactory]); + $postB = create(Post::class, ['title' => 'title', 'body' => 'body', 'category' => $category]); + + $this->assertSame('foo', $postA->getCategory()->getName()); + $this->assertSame('bar', $postB->getCategory()->getName()); + } + + /** + * @test + */ + public function one_to_many_relationship(): void + { + $category = create(Category::class, [ + 'name' => 'bar', + 'posts' => [ + factory(Post::class, ['title' => 'Post A', 'body' => 'body']), + create(Post::class, ['title' => 'Post B', 'body' => 'body']), + ], + ]); + + $posts = \array_map( + static function($post) { + return $post->getTitle(); + }, + $category->getPosts()->toArray() + ); + + $this->assertCount(2, $posts); + $this->assertContains('Post A', $posts); + $this->assertContains('Post B', $posts); + } + + /** + * @test + */ + public function many_to_many_relationship(): void + { + $post = create(Post::class, [ + 'title' => 'title', + 'body' => 'body', + 'tags' => [ + factory(Tag::class, ['name' => 'Tag A']), + create(Tag::class, ['name' => 'Tag B']), + ], + ]); + + $tags = \array_map( + static function($tag) { + return $tag->getName(); + }, + $post->getTags()->toArray() + ); + + $this->assertCount(2, $tags); + $this->assertContains('Tag A', $tags); + $this->assertContains('Tag B', $tags); + } + + /** + * @test + */ + public function many_to_many_reverse_relationship(): void + { + $tag = create(Tag::class, [ + 'name' => 'bar', + 'posts' => [ + factory(Post::class, ['title' => 'Post A', 'body' => 'body']), + create(Post::class, ['title' => 'Post B', 'body' => 'body']), + ], + ]); + + $posts = \array_map( + static function($post) { + return $post->getTitle(); + }, + $tag->getPosts()->toArray() + ); + + $this->assertCount(2, $posts); + $this->assertContains('Post A', $posts); + $this->assertContains('Post B', $posts); + } + + /** + * @test + */ + public function creating_with_factory_attribute_persists_the_factory(): void + { + $object = (new Factory(Post::class))->create([ + 'title' => 'title', + 'body' => 'body', + 'category' => new Factory(Category::class, ['name' => 'name']), + ]); + + $this->assertNotNull($object->getCategory()->getId()); + } +} diff --git a/tests/Functional/ModelFactoryTest.php b/tests/Functional/ModelFactoryTest.php index 94bb0a142..91404ff19 100644 --- a/tests/Functional/ModelFactoryTest.php +++ b/tests/Functional/ModelFactoryTest.php @@ -3,6 +3,10 @@ namespace Zenstruck\Foundry\Tests\Functional; use Zenstruck\Foundry\Tests\Fixtures\Factories\CategoryFactory; +use Zenstruck\Foundry\Tests\Fixtures\Factories\PostFactory; +use Zenstruck\Foundry\Tests\Fixtures\Factories\PostFactoryWithInvalidInitialize; +use Zenstruck\Foundry\Tests\Fixtures\Factories\PostFactoryWithNullInitialize; +use Zenstruck\Foundry\Tests\Fixtures\Factories\PostFactoryWithValidInitialize; use Zenstruck\Foundry\Tests\FunctionalTestCase; /** @@ -21,4 +25,86 @@ public function can_find_or_create(): void CategoryFactory::findOrCreate(['name' => 'php']); CategoryFactory::repository()->assertCount(1); } + + /** + * @test + */ + public function can_override_initialize(): void + { + $this->assertFalse(PostFactory::new()->create()->isPublished()); + $this->assertTrue(PostFactoryWithValidInitialize::new()->create()->isPublished()); + } + + /** + * @test + */ + public function initialize_must_return_an_instance_of_the_current_factory(): void + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage(\sprintf('"%1$s::initialize()" must return an instance of "%1$s".', PostFactoryWithInvalidInitialize::class)); + + PostFactoryWithInvalidInitialize::new(); + } + + /** + * @test + */ + public function initialize_must_return_a_value(): void + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage(\sprintf('"%1$s::initialize()" must return an instance of "%1$s".', PostFactoryWithNullInitialize::class)); + + PostFactoryWithNullInitialize::new(); + } + + /** + * @test + */ + public function can_find_random_object(): void + { + CategoryFactory::new()->createMany(5); + + $ids = []; + + while (5 !== \count(\array_unique($ids))) { + $ids[] = CategoryFactory::random()->getId(); + } + + $this->assertCount(5, \array_unique($ids)); + } + + /** + * @test + */ + public function can_find_random_set_of_objects(): void + { + CategoryFactory::new()->createMany(5); + + $objects = CategoryFactory::randomSet(3); + + $this->assertCount(3, $objects); + $this->assertCount(3, \array_unique(\array_map(static function($category) { return $category->getId(); }, $objects))); + } + + /** + * @test + */ + public function can_find_random_range_of_objects(): void + { + CategoryFactory::new()->createMany(5); + + $counts = []; + + while (4 !== \count(\array_unique($counts))) { + $counts[] = \count(CategoryFactory::randomRange(0, 3)); + } + + $this->assertCount(4, \array_unique($counts)); + $this->assertContains(0, $counts); + $this->assertContains(1, $counts); + $this->assertContains(2, $counts); + $this->assertContains(3, $counts); + $this->assertNotContains(4, $counts); + $this->assertNotContains(5, $counts); + } } diff --git a/tests/Functional/ProxyTest.php b/tests/Functional/ProxyTest.php index e1e0ae1a1..82dd465a3 100644 --- a/tests/Functional/ProxyTest.php +++ b/tests/Functional/ProxyTest.php @@ -17,7 +17,7 @@ final class ProxyTest extends FunctionalTestCase */ public function can_assert_persisted(): void { - $post = PostFactory::create(); + $post = PostFactory::new()->create(); $post->assertPersisted(); } @@ -27,7 +27,7 @@ public function can_assert_persisted(): void */ public function can_remove_and_assert_not_persisted(): void { - $post = PostFactory::create(); + $post = PostFactory::new()->create(); $post->remove(); @@ -39,7 +39,7 @@ public function can_remove_and_assert_not_persisted(): void */ public function functions_are_passed_to_wrapped_object(): void { - $post = PostFactory::create(['title' => 'my title']); + $post = PostFactory::new()->create(['title' => 'my title']); $this->assertSame('my title', $post->getTitle()); } @@ -49,20 +49,30 @@ public function functions_are_passed_to_wrapped_object(): void */ public function can_convert_to_string_if_wrapped_object_can(): void { - $post = PostFactory::create(['title' => 'my title']); + $post = PostFactory::new()->create(['title' => 'my title']); $this->assertSame('my title', (string) $post); } /** * @test + * @requires PHP >= 7.4 */ public function cannot_convert_to_string_if_underlying_object_cant(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage(\sprintf('Proxied object "%s" cannot be converted to a string.', Category::class)); - (string) CategoryFactory::create(); + (string) CategoryFactory::new()->create(); + } + + /** + * @test + * @requires PHP < 7.4 + */ + public function on_php_versions_less_than_7_4_if_underlying_object_is_missing_to_string_proxy_to_string_returns_note(): void + { + $this->assertSame('(no __toString)', (string) CategoryFactory::new()->create()); } /** @@ -70,7 +80,7 @@ public function cannot_convert_to_string_if_underlying_object_cant(): void */ public function can_refetch_object_if_object_manager_has_been_cleared(): void { - $post = PostFactory::create(['title' => 'my title']); + $post = PostFactory::new()->create(['title' => 'my title']); self::$container->get('doctrine')->getManager()->clear(); @@ -82,7 +92,7 @@ public function can_refetch_object_if_object_manager_has_been_cleared(): void */ public function exception_thrown_if_trying_to_refresh_deleted_object(): void { - $post = PostFactory::create(); + $post = PostFactory::new()->create(); self::$container->get('doctrine')->getManager()->clear(); @@ -99,7 +109,7 @@ public function exception_thrown_if_trying_to_refresh_deleted_object(): void */ public function can_force_set_and_save(): void { - $post = PostFactory::create(['title' => 'my title']); + $post = PostFactory::new()->create(['title' => 'old title']); $post->repository()->assertNotExists(['title' => 'new title']); @@ -107,4 +117,25 @@ public function can_force_set_and_save(): void $post->repository()->assertExists(['title' => 'new title']); } + + /** + * @test + */ + public function can_force_set_multiple_fields_if_auto_refreshing_is_disabled(): void + { + $post = PostFactory::new()->create(['title' => 'old title', 'body' => 'old body']); + + $this->assertSame('old title', $post->getTitle()); + $this->assertSame('old body', $post->getBody()); + + $post + ->disableAutoRefresh() + ->forceSet('title', 'new title') + ->forceSet('body', 'new body') + ->save() + ; + + $this->assertSame('new title', $post->getTitle()); + $this->assertSame('new body', $post->getBody()); + } } diff --git a/tests/Functional/RepositoryProxyTest.php b/tests/Functional/RepositoryProxyTest.php index 250203dd9..e5048f2f1 100644 --- a/tests/Functional/RepositoryProxyTest.php +++ b/tests/Functional/RepositoryProxyTest.php @@ -31,7 +31,7 @@ public function assertions(): void $repository->assertEmpty(); - CategoryFactory::createMany(2); + CategoryFactory::new()->createMany(2); $repository->assertCount(2); $repository->assertCountGreaterThan(1); @@ -47,7 +47,7 @@ public function can_fetch_objects(): void { $repository = repository(Category::class); - CategoryFactory::createMany(2); + CategoryFactory::new()->createMany(2); $object = $repository->first(); @@ -70,10 +70,131 @@ public function can_fetch_objects(): void public function find_can_be_passed_proxy_or_object_or_array(): void { $repository = repository(Category::class); - $proxy = CategoryFactory::create(['name' => 'foo']); + $proxy = CategoryFactory::new()->create(['name' => 'foo']); $this->assertInstanceOf(Proxy::class, $repository->find($proxy)); $this->assertInstanceOf(Proxy::class, $repository->find($proxy->object())); $this->assertInstanceOf(Proxy::class, $repository->find(['name' => 'foo'])); } + + /** + * @test + */ + public function can_find_random_object(): void + { + CategoryFactory::new()->createMany(5); + + $ids = []; + + while (5 !== \count(\array_unique($ids))) { + $ids[] = repository(Category::class)->random()->getId(); + } + + $this->assertCount(5, \array_unique($ids)); + } + + /** + * @test + */ + public function at_least_one_object_must_exist_to_get_random_object(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(\sprintf('At least 1 "%s" object(s) must have been persisted (0 persisted).', Category::class)); + + repository(Category::class)->random(); + } + + /** + * @test + */ + public function can_find_random_set_of_objects(): void + { + CategoryFactory::new()->createMany(5); + + $objects = repository(Category::class)->randomSet(3); + + $this->assertCount(3, $objects); + $this->assertCount(3, \array_unique(\array_map(static function($category) { return $category->getId(); }, $objects))); + } + + /** + * @test + */ + public function random_set_number_must_be_positive(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('$number must be positive (-1 given).'); + + repository(Category::class)->randomSet(-1); + } + + /** + * @test + */ + public function the_number_of_persisted_objects_must_be_at_least_the_random_set_number(): void + { + CategoryFactory::new()->createMany(1); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(\sprintf('At least 2 "%s" object(s) must have been persisted (1 persisted).', Category::class)); + + repository(Category::class)->randomSet(2); + } + + /** + * @test + */ + public function can_find_random_range_of_objects(): void + { + CategoryFactory::new()->createMany(5); + + $counts = []; + + while (4 !== \count(\array_unique($counts))) { + $counts[] = \count(repository(Category::class)->randomRange(0, 3)); + } + + $this->assertCount(4, \array_unique($counts)); + $this->assertContains(0, $counts); + $this->assertContains(1, $counts); + $this->assertContains(2, $counts); + $this->assertContains(3, $counts); + $this->assertNotContains(4, $counts); + $this->assertNotContains(5, $counts); + } + + /** + * @test + */ + public function the_number_of_persisted_objects_must_be_at_least_the_random_range_max(): void + { + CategoryFactory::new()->createMany(1); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(\sprintf('At least 2 "%s" object(s) must have been persisted (1 persisted).', Category::class)); + + repository(Category::class)->randomRange(0, 2); + } + + /** + * @test + */ + public function random_range_min_cannot_be_less_than_zero(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('$min must be positive (-1 given).'); + + repository(Category::class)->randomRange(-1, 3); + } + + /** + * @test + */ + public function random_set_max_cannot_be_less_than_min(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('$max (3) cannot be less than $min (5).'); + + repository(Category::class)->randomRange(5, 3); + } } diff --git a/tests/Functional/StoryTest.php b/tests/Functional/StoryTest.php index d2244a3e0..ecdda2097 100644 --- a/tests/Functional/StoryTest.php +++ b/tests/Functional/StoryTest.php @@ -5,6 +5,7 @@ use Zenstruck\Foundry\Tests\Fixtures\Factories\PostFactory; use Zenstruck\Foundry\Tests\Fixtures\Stories\CategoryStory; use Zenstruck\Foundry\Tests\Fixtures\Stories\PostStory; +use Zenstruck\Foundry\Tests\Fixtures\Stories\ServiceStory; use Zenstruck\Foundry\Tests\FunctionalTestCase; /** @@ -51,4 +52,16 @@ public function cannot_access_invalid_object(): void CategoryStory::load()->get('invalid'); } + + /** + * @test + */ + public function stories_can_be_services(): void + { + if (!\getenv('USE_FOUNDRY_BUNDLE')) { + $this->markTestSkipped('Stories cannot be services without the foundry bundle.'); + } + + $this->assertSame('From Service', ServiceStory::post()->getTitle()); + } } diff --git a/tests/ResetGlobals.php b/tests/ResetGlobals.php deleted file mode 100644 index 52f6d4ab7..000000000 --- a/tests/ResetGlobals.php +++ /dev/null @@ -1,35 +0,0 @@ - - */ -trait ResetGlobals -{ - /** - * @before - */ - public static function resetGlobals() - { - $reset = static function($class, $property, $value) { - $property = (new \ReflectionClass($class))->getProperty($property); - $property->setAccessible(true); - $property->setValue($value); - }; - - $reset(Factory::class, 'defaultInstantiator', null); - $reset(Factory::class, 'proxyByDefault', true); - $reset(Factory::class, 'faker', null); - $reset(PersistenceManager::class, 'managerRegistry', null); - $reset(Proxy::class, 'autoRefreshByDefault', true); - $reset(Proxy::class, 'instantiator', null); - $reset(StoryManager::class, 'globalInstances', []); - $reset(StoryManager::class, 'instances', []); - } -} diff --git a/tests/Unit/Bundle/DependencyInjection/ZenstruckFoundryExtensionTest.php b/tests/Unit/Bundle/DependencyInjection/ZenstruckFoundryExtensionTest.php new file mode 100644 index 000000000..104590bc8 --- /dev/null +++ b/tests/Unit/Bundle/DependencyInjection/ZenstruckFoundryExtensionTest.php @@ -0,0 +1,137 @@ + + */ +final class ZenstruckFoundryExtensionTest extends AbstractExtensionTestCase +{ + /** + * @test + */ + public function default_config(): void + { + $this->load(); + + $this->assertContainerBuilderHasService(Configuration::class); + $this->assertContainerBuilderHasServiceDefinitionWithMethodCall(Configuration::class, 'setInstantiator', ['zenstruck_foundry.default_instantiator']); + $this->assertContainerBuilderHasServiceDefinitionWithMethodCall(Configuration::class, 'setFaker', ['zenstruck_foundry.faker']); + $this->assertTrue($this->container->getDefinition(Configuration::class)->isPublic()); + $this->assertContainerBuilderHasService('zenstruck_foundry.default_instantiator', Instantiator::class); + $this->assertEmpty($this->container->getDefinition('zenstruck_foundry.default_instantiator')->getMethodCalls()); + $this->assertContainerBuilderHasService('zenstruck_foundry.faker', Generator::class); + $this->assertEmpty($this->container->getDefinition('zenstruck_foundry.faker')->getArguments()); + $this->assertContainerBuilderHasService(StoryManager::class); + $this->assertContainerBuilderHasServiceDefinitionWithTag(MakeFactory::class, 'maker.command'); + $this->assertContainerBuilderHasServiceDefinitionWithTag(MakeStory::class, 'maker.command'); + } + + /** + * @test + */ + public function custom_faker_locale(): void + { + $this->load(['faker' => ['locale' => 'fr_FR']]); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument('zenstruck_foundry.faker', 0, 'fr_FR'); + } + + /** + * @test + */ + public function custom_faker_service(): void + { + $this->load(['faker' => ['service' => 'my_faker']]); + + $this->assertContainerBuilderHasService(Configuration::class); + $this->assertContainerBuilderHasServiceDefinitionWithMethodCall(Configuration::class, 'setFaker', ['zenstruck_foundry.faker']); + $this->assertContainerBuilderHasAlias('zenstruck_foundry.faker', 'my_faker'); + } + + /** + * @test + */ + public function cannot_set_faker_locale_and_service(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid configuration for path "zenstruck_foundry.faker": Cannot set faker locale when using custom service.'); + + $this->load(['faker' => ['service' => 'my_faker', 'locale' => 'fr_FR']]); + } + + /** + * @test + */ + public function custom_instantiator_config(): void + { + $this->load(['instantiator' => [ + 'without_constructor' => true, + 'allow_extra_attributes' => true, + 'always_force_properties' => true, + ]]); + + $this->assertContainerBuilderHasServiceDefinitionWithMethodCall('zenstruck_foundry.default_instantiator', 'withoutConstructor'); + $this->assertContainerBuilderHasServiceDefinitionWithMethodCall('zenstruck_foundry.default_instantiator', 'allowExtraAttributes'); + $this->assertContainerBuilderHasServiceDefinitionWithMethodCall('zenstruck_foundry.default_instantiator', 'alwaysForceProperties'); + } + + /** + * @test + */ + public function custom_instantiator_service(): void + { + $this->load(['instantiator' => ['service' => 'my_instantiator']]); + + $this->assertContainerBuilderHasService(Configuration::class); + $this->assertContainerBuilderHasAlias('zenstruck_foundry.default_instantiator', 'my_instantiator'); + } + + /** + * @test + */ + public function cannot_configure_allow_extra_attributes_if_using_custom_instantiator_service(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid configuration for path "zenstruck_foundry.instantiator": Cannot set "allow_extra_attributes" when using custom service.'); + + $this->load(['instantiator' => ['service' => 'my_instantiator', 'allow_extra_attributes' => true]]); + } + + /** + * @test + */ + public function cannot_configure_without_constructor_if_using_custom_instantiator_service(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid configuration for path "zenstruck_foundry.instantiator": Cannot set "without_constructor" when using custom service.'); + + $this->load(['instantiator' => ['service' => 'my_instantiator', 'without_constructor' => true]]); + } + + /** + * @test + */ + public function cannot_configure_always_force_properties_if_using_custom_instantiator_service(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid configuration for path "zenstruck_foundry.instantiator": Cannot set "always_force_properties" when using custom service.'); + + $this->load(['instantiator' => ['service' => 'my_instantiator', 'always_force_properties' => true]]); + } + + protected function getContainerExtensions(): array + { + return [new ZenstruckFoundryExtension()]; + } +} diff --git a/tests/Unit/CustomFactoryTest.php b/tests/Unit/CustomFactoryTest.php deleted file mode 100644 index 61971021e..000000000 --- a/tests/Unit/CustomFactoryTest.php +++ /dev/null @@ -1,49 +0,0 @@ - - */ -final class CustomFactoryTest extends TestCase -{ - /** - * @test - */ - public function can_set_states_with_method(): void - { - $this->assertFalse(PostFactory::new()->instantiate()->isPublished()); - $this->assertTrue(PostFactory::new()->published()->instantiate()->isPublished()); - } - - /** - * @test - */ - public function can_set_state_via_new(): void - { - $this->assertFalse(PostFactory::new()->instantiate()->isPublished()); - $this->assertTrue(PostFactory::new('published')->instantiate()->isPublished()); - } - - /** - * @test - */ - public function can_make(): void - { - $this->assertSame('title', PostFactory::make(['title' => 'title'])->getTitle()); - } - - /** - * @test - */ - public function can_make_many(): void - { - $objects = PostFactory::makeMany(2, ['title' => 'title']); - - $this->assertCount(2, $objects); - $this->assertSame('title', $objects[0]->getTitle()); - } -} diff --git a/tests/Unit/FactoryTest.php b/tests/Unit/FactoryTest.php index 5cd3b2b4d..902afedce 100644 --- a/tests/Unit/FactoryTest.php +++ b/tests/Unit/FactoryTest.php @@ -5,35 +5,33 @@ use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager; use Faker; -use PHPUnit\Framework\TestCase; use Zenstruck\Foundry\Factory; -use Zenstruck\Foundry\PersistenceManager; use Zenstruck\Foundry\Proxy; use Zenstruck\Foundry\Tests\Fixtures\Entity\Category; use Zenstruck\Foundry\Tests\Fixtures\Entity\Post; -use Zenstruck\Foundry\Tests\ResetGlobals; +use Zenstruck\Foundry\Tests\UnitTestCase; /** * @author Kevin Bond */ -final class FactoryTest extends TestCase +final class FactoryTest extends UnitTestCase { - use ResetGlobals; - /** * @test */ public function can_instantiate_object(): void { $attributeArray = ['title' => 'title', 'body' => 'body']; - $attributeCallback = fn(Faker\Generator $faker) => ['title' => 'title', 'body' => 'body']; - - $this->assertSame('title', (new Factory(Post::class, $attributeArray))->instantiate()->getTitle()); - $this->assertSame('title', (new Factory(Post::class))->instantiate($attributeArray)->getTitle()); - $this->assertSame('title', (new Factory(Post::class))->withAttributes($attributeArray)->instantiate()->getTitle()); - $this->assertSame('title', (new Factory(Post::class, $attributeCallback))->instantiate()->getTitle()); - $this->assertSame('title', (new Factory(Post::class))->instantiate($attributeCallback)->getTitle()); - $this->assertSame('title', (new Factory(Post::class))->withAttributes($attributeCallback)->instantiate()->getTitle()); + $attributeCallback = static function(Faker\Generator $faker) { + return ['title' => 'title', 'body' => 'body']; + }; + + $this->assertSame('title', (new Factory(Post::class, $attributeArray))->withoutPersisting()->create()->getTitle()); + $this->assertSame('title', (new Factory(Post::class))->withoutPersisting()->create($attributeArray)->getTitle()); + $this->assertSame('title', (new Factory(Post::class))->withoutPersisting()->withAttributes($attributeArray)->create()->getTitle()); + $this->assertSame('title', (new Factory(Post::class, $attributeCallback))->withoutPersisting()->create()->getTitle()); + $this->assertSame('title', (new Factory(Post::class))->withoutPersisting()->create($attributeCallback)->getTitle()); + $this->assertSame('title', (new Factory(Post::class))->withAttributes($attributeCallback)->withoutPersisting()->create()->getTitle()); } /** @@ -42,44 +40,46 @@ public function can_instantiate_object(): void public function can_instantiate_many_objects(): void { $attributeArray = ['title' => 'title', 'body' => 'body']; - $attributeCallback = fn(Faker\Generator $faker) => ['title' => 'title', 'body' => 'body']; + $attributeCallback = static function(Faker\Generator $faker) { + return ['title' => 'title', 'body' => 'body']; + }; - $objects = (new Factory(Post::class, $attributeArray))->instantiateMany(3); + $objects = (new Factory(Post::class, $attributeArray))->withoutPersisting()->createMany(3); $this->assertCount(3, $objects); $this->assertSame('title', $objects[0]->getTitle()); $this->assertSame('title', $objects[1]->getTitle()); $this->assertSame('title', $objects[2]->getTitle()); - $objects = (new Factory(Post::class))->instantiateMany(3, $attributeArray); + $objects = (new Factory(Post::class))->withoutPersisting()->createMany(3, $attributeArray); $this->assertCount(3, $objects); $this->assertSame('title', $objects[0]->getTitle()); $this->assertSame('title', $objects[1]->getTitle()); $this->assertSame('title', $objects[2]->getTitle()); - $objects = (new Factory(Post::class))->withAttributes($attributeArray)->instantiateMany(3); + $objects = (new Factory(Post::class))->withAttributes($attributeArray)->withoutPersisting()->createMany(3); $this->assertCount(3, $objects); $this->assertSame('title', $objects[0]->getTitle()); $this->assertSame('title', $objects[1]->getTitle()); $this->assertSame('title', $objects[2]->getTitle()); - $objects = (new Factory(Post::class, $attributeCallback))->instantiateMany(3); + $objects = (new Factory(Post::class, $attributeCallback))->withoutPersisting()->createMany(3); $this->assertCount(3, $objects); $this->assertSame('title', $objects[0]->getTitle()); $this->assertSame('title', $objects[1]->getTitle()); $this->assertSame('title', $objects[2]->getTitle()); - $objects = (new Factory(Post::class))->instantiateMany(3, $attributeCallback); + $objects = (new Factory(Post::class))->withoutPersisting()->createMany(3, $attributeCallback); $this->assertCount(3, $objects); $this->assertSame('title', $objects[0]->getTitle()); $this->assertSame('title', $objects[1]->getTitle()); $this->assertSame('title', $objects[2]->getTitle()); - $objects = (new Factory(Post::class))->withAttributes($attributeCallback)->instantiateMany(3); + $objects = (new Factory(Post::class))->withAttributes($attributeCallback)->withoutPersisting()->createMany(3); $this->assertCount(3, $objects); $this->assertSame('title', $objects[0]->getTitle()); @@ -94,12 +94,16 @@ public function can_set_instantiator(): void { $attributeArray = ['title' => 'original title', 'body' => 'original body']; - $object = (new Factory(Post::class))->instantiator(function(array $attributes, string $class) use ($attributeArray) { - $this->assertSame(Post::class, $class); - $this->assertSame($attributes, $attributeArray); + $object = (new Factory(Post::class)) + ->instantiateWith(function(array $attributes, string $class) use ($attributeArray) { + $this->assertSame(Post::class, $class); + $this->assertSame($attributes, $attributeArray); - return new Post('title', 'body'); - })->instantiate($attributeArray); + return new Post('title', 'body'); + }) + ->withoutPersisting() + ->create($attributeArray) + ; $this->assertSame('title', $object->getTitle()); $this->assertSame('body', $object->getBody()); @@ -123,7 +127,8 @@ public function can_add_before_instantiate_events(): void return $attributes; }) - ->instantiate($attributeArray) + ->withoutPersisting() + ->create($attributeArray) ; $this->assertSame('title', $object->getTitle()); @@ -138,7 +143,7 @@ public function before_instantiate_event_must_return_an_array(): void $this->expectException(\LogicException::class); $this->expectExceptionMessage('Before Instantiate event callback must return an array.'); - (new Factory(Post::class))->beforeInstantiate(function() {})->instantiate(); + (new Factory(Post::class))->beforeInstantiate(function() {})->withoutPersisting()->create(); } /** @@ -159,7 +164,8 @@ public function can_add_after_instantiate_events(): void $post->increaseViewCount(); }) - ->instantiate($attributesArray) + ->withoutPersisting() + ->create($attributesArray) ; $this->assertSame(2, $object->getViewCount()); @@ -170,11 +176,10 @@ public function can_add_after_instantiate_events(): void */ public function can_register_custom_faker(): void { - $faker = Factory::faker(); - - Factory::registerFaker(new Faker\Generator()); + $defaultFaker = Factory::faker(); + Factory::configuration()->setFaker(new Faker\Generator()); - $this->assertNotSame(\spl_object_id(Factory::faker()), \spl_object_id($faker)); + $this->assertNotSame(\spl_object_id(Factory::faker()), \spl_object_id($defaultFaker)); } /** @@ -182,11 +187,11 @@ public function can_register_custom_faker(): void */ public function can_register_default_instantiator(): void { - Factory::registerDefaultInstantiator(function() { + $this->configuration->setInstantiator(function() { return new Post('different title', 'different body'); }); - $object = (new Factory(Post::class, ['title' => 'title', 'body' => 'body']))->instantiate(); + $object = (new Factory(Post::class, ['title' => 'title', 'body' => 'body']))->withoutPersisting()->create(); $this->assertSame('different title', $object->getTitle()); $this->assertSame('different body', $object->getBody()); @@ -197,10 +202,10 @@ public function can_register_default_instantiator(): void */ public function instantiating_with_proxy_attribute_normalizes_to_underlying_object(): void { - $object = (new Factory(Post::class))->instantiate([ + $object = (new Factory(Post::class))->withoutPersisting()->create([ 'title' => 'title', 'body' => 'body', - 'category' => (new Proxy(new Category()))->withoutAutoRefresh(), + 'category' => new Proxy(new Category()), ]); $this->assertInstanceOf(Category::class, $object->getCategory()); @@ -211,7 +216,7 @@ public function instantiating_with_proxy_attribute_normalizes_to_underlying_obje */ public function instantiating_with_factory_attribute_instantiates_the_factory(): void { - $object = (new Factory(Post::class))->instantiate([ + $object = (new Factory(Post::class))->withoutPersisting()->create([ 'title' => 'title', 'body' => 'body', 'category' => new Factory(Category::class), @@ -229,7 +234,8 @@ public function factory_is_immutable(): void $objectId = \spl_object_id($factory); $this->assertNotSame(\spl_object_id($factory->withAttributes([])), $objectId); - $this->assertNotSame(\spl_object_id($factory->instantiator(function() {})), $objectId); + $this->assertNotSame(\spl_object_id($factory->withoutPersisting()), $objectId); + $this->assertNotSame(\spl_object_id($factory->instantiateWith(function() {})), $objectId); $this->assertNotSame(\spl_object_id($factory->beforeInstantiate(function() {})), $objectId); $this->assertNotSame(\spl_object_id($factory->afterInstantiate(function() {})), $objectId); $this->assertNotSame(\spl_object_id($factory->afterPersist(function() {})), $objectId); @@ -238,117 +244,48 @@ public function factory_is_immutable(): void /** * @test */ - public function can_persist_object(): void + public function can_create_object(): void { $registry = $this->createMock(ManagerRegistry::class); $registry - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('getManagerForClass') ->with(Post::class) ->willReturn($this->createMock(ObjectManager::class)) ; - PersistenceManager::register($registry); + $this->configuration->setManagerRegistry($registry); - $object = (new Factory(Post::class))->persist(['title' => 'title', 'body' => 'body']); + $object = (new Factory(Post::class))->create(['title' => 'title', 'body' => 'body']); $this->assertInstanceOf(Proxy::class, $object); - $this->assertSame('title', $object->withoutAutoRefresh()->getTitle()); + $this->assertSame('title', $object->disableAutoRefresh()->getTitle()); } /** * @test */ - public function can_persist_many_objects(): void + public function can_create_many_objects(): void { $registry = $this->createMock(ManagerRegistry::class); $registry - ->expects($this->exactly(3)) + ->expects($this->exactly(6)) ->method('getManagerForClass') ->with(Post::class) ->willReturn($this->createMock(ObjectManager::class)) ; - PersistenceManager::register($registry); + $this->configuration->setManagerRegistry($registry); - $objects = (new Factory(Post::class))->persistMany(3, ['title' => 'title', 'body' => 'body']); + $objects = (new Factory(Post::class))->createMany(3, ['title' => 'title', 'body' => 'body']); $this->assertCount(3, $objects); $this->assertInstanceOf(Proxy::class, $objects[0]); $this->assertInstanceOf(Proxy::class, $objects[1]); $this->assertInstanceOf(Proxy::class, $objects[2]); - $this->assertSame('title', $objects[0]->withoutAutoRefresh()->getTitle()); - $this->assertSame('title', $objects[1]->withoutAutoRefresh()->getTitle()); - $this->assertSame('title', $objects[2]->withoutAutoRefresh()->getTitle()); - } - - /** - * @test - */ - public function can_disable_proxy_when_persisting(): void - { - $registry = $this->createMock(ManagerRegistry::class); - $registry - ->expects($this->once()) - ->method('getManagerForClass') - ->with(Post::class) - ->willReturn($this->createMock(ObjectManager::class)) - ; - - PersistenceManager::register($registry); - - $object = (new Factory(Post::class))->persist(['title' => 'title', 'body' => 'body'], false); - - $this->assertInstanceOf(Post::class, $object); - $this->assertSame('title', $object->getTitle()); - } - - /** - * @test - */ - public function can_globally_disable_proxying(): void - { - Factory::proxyByDefault(false); - - $registry = $this->createMock(ManagerRegistry::class); - $registry - ->expects($this->once()) - ->method('getManagerForClass') - ->with(Post::class) - ->willReturn($this->createMock(ObjectManager::class)) - ; - - PersistenceManager::register($registry); - - $object = (new Factory(Post::class))->persist(['title' => 'title', 'body' => 'body']); - - $this->assertInstanceOf(Post::class, $object); - $this->assertSame('title', $object->getTitle()); - } - - /** - * @test - */ - public function persiting_with_factory_attribute_persists_the_factory(): void - { - $registry = $this->createMock(ManagerRegistry::class); - $registry - ->expects($this->exactly(2)) - ->method('getManagerForClass') - ->withConsecutive([Category::class], [Post::class]) - ->willReturn($this->createMock(ObjectManager::class)) - ; - - PersistenceManager::register($registry); - - $object = (new Factory(Post::class))->persist([ - 'title' => 'title', - 'body' => 'body', - 'category' => new Factory(Category::class), - ]); - - $this->assertInstanceOf(Proxy::class, $object); - $this->assertInstanceOf(Category::class, $object->withoutAutoRefresh()->getCategory()); + $this->assertSame('title', $objects[0]->disableAutoRefresh()->getTitle()); + $this->assertSame('title', $objects[1]->disableAutoRefresh()->getTitle()); + $this->assertSame('title', $objects[2]->disableAutoRefresh()->getTitle()); } /** @@ -358,30 +295,49 @@ public function can_add_after_persist_events(): void { $registry = $this->createMock(ManagerRegistry::class); $registry - ->expects($this->exactly(3)) // once for persisting, once for each afterPersist event + ->expects($this->exactly(2)) // once for persisting, once for each afterPersist event ->method('getManagerForClass') ->with(Post::class) ->willReturn($this->createMock(ObjectManager::class)) ; - PersistenceManager::register($registry); + $this->configuration->setManagerRegistry($registry); $attributesArray = ['title' => 'title', 'body' => 'body']; + $calls = 0; $object = (new Factory(Post::class)) - ->afterPersist(function(Post $post, array $attributes) use ($attributesArray) { + ->afterPersist(function(Proxy $post, array $attributes) use ($attributesArray, &$calls) { + /* @var Post $post */ + $this->assertSame($attributesArray, $attributes); + + $post->increaseViewCount(); + ++$calls; + }) + ->afterPersist(function(Post $post, array $attributes) use ($attributesArray, &$calls) { $this->assertSame($attributesArray, $attributes); $post->increaseViewCount(); + ++$calls; }) - ->afterPersist(function(Post $post, array $attributes) use ($attributesArray) { + ->afterPersist(function(Post $post, array $attributes) use ($attributesArray, &$calls) { $this->assertSame($attributesArray, $attributes); $post->increaseViewCount(); + ++$calls; + }) + ->afterPersist(function($post) use (&$calls) { + $this->assertInstanceOf(Proxy::class, $post); + + ++$calls; + }) + ->afterPersist(static function() use (&$calls) { + ++$calls; }) - ->persist($attributesArray) + ->create($attributesArray) ; - $this->assertSame(2, $object->withoutAutoRefresh()->getViewCount()); + $this->assertSame(3, $object->disableAutoRefresh()->getViewCount()); + $this->assertSame(5, $calls); } } diff --git a/tests/Unit/FunctionsTest.php b/tests/Unit/FunctionsTest.php index 34ff47588..062188f9b 100644 --- a/tests/Unit/FunctionsTest.php +++ b/tests/Unit/FunctionsTest.php @@ -5,27 +5,23 @@ use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectRepository; -use PHPUnit\Framework\TestCase; -use Zenstruck\Foundry\PersistenceManager; use Zenstruck\Foundry\Proxy; use Zenstruck\Foundry\RepositoryProxy; use Zenstruck\Foundry\Tests\Fixtures\Entity\Category; use Zenstruck\Foundry\Tests\Fixtures\Entity\Post; -use Zenstruck\Foundry\Tests\ResetGlobals; +use Zenstruck\Foundry\Tests\UnitTestCase; +use function Zenstruck\Foundry\create; +use function Zenstruck\Foundry\create_many; use function Zenstruck\Foundry\faker; use function Zenstruck\Foundry\instantiate; use function Zenstruck\Foundry\instantiate_many; -use function Zenstruck\Foundry\persist; -use function Zenstruck\Foundry\persist_many; use function Zenstruck\Foundry\repository; /** * @author Kevin Bond */ -final class FunctionsTest extends TestCase +final class FunctionsTest extends UnitTestCase { - use ResetGlobals; - /** * @test */ @@ -39,10 +35,11 @@ public function faker(): void */ public function instantiate(): void { - $object = instantiate(Post::class, ['title' => 'title', 'body' => 'body']); + $proxy = instantiate(Post::class, ['title' => 'title', 'body' => 'body']); - $this->assertInstanceOf(Post::class, $object); - $this->assertSame('title', $object->getTitle()); + $this->assertInstanceOf(Post::class, $proxy->object()); + $this->assertFalse($proxy->isPersisted()); + $this->assertSame('title', $proxy->getTitle()); } /** @@ -53,25 +50,26 @@ public function instantiate_many(): void $objects = instantiate_many(3, Category::class); $this->assertCount(3, $objects); - $this->assertInstanceOf(Category::class, $objects[0]); + $this->assertInstanceOf(Category::class, $objects[0]->object()); + $this->assertFalse($objects[0]->isPersisted()); } /** * @test */ - public function persist(): void + public function create(): void { $registry = $this->createMock(ManagerRegistry::class); $registry - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('getManagerForClass') ->with(Category::class) ->willReturn($this->createMock(ObjectManager::class)) ; - PersistenceManager::register($registry); + $this->configuration->setManagerRegistry($registry); - $object = persist(Category::class); + $object = create(Category::class); $this->assertInstanceOf(Proxy::class, $object); } @@ -79,19 +77,19 @@ public function persist(): void /** * @test */ - public function persist_many(): void + public function create_many(): void { $registry = $this->createMock(ManagerRegistry::class); $registry - ->expects($this->exactly(3)) + ->expects($this->exactly(6)) ->method('getManagerForClass') ->with(Category::class) ->willReturn($this->createMock(ObjectManager::class)) ; - PersistenceManager::register($registry); + $this->configuration->setManagerRegistry($registry); - $objects = persist_many(3, Category::class); + $objects = create_many(3, Category::class); $this->assertCount(3, $objects); } @@ -109,7 +107,7 @@ public function repository(): void ->willReturn($this->createMock(ObjectRepository::class)) ; - PersistenceManager::register($registry); + $this->configuration->setManagerRegistry($registry); $this->assertInstanceOf(RepositoryProxy::class, repository(new Category())); } diff --git a/tests/Unit/InstantiatorTest.php b/tests/Unit/InstantiatorTest.php index 49c52bfb5..320724d2f 100644 --- a/tests/Unit/InstantiatorTest.php +++ b/tests/Unit/InstantiatorTest.php @@ -4,7 +4,6 @@ use PHPUnit\Framework\TestCase; use Zenstruck\Foundry\Instantiator; -use Zenstruck\Foundry\Tests\Fixtures\Entity\Post; /** * @author Kevin Bond @@ -14,252 +13,467 @@ final class InstantiatorTest extends TestCase /** * @test */ - public function default_mode_calls_constructor_and_sets_properties(): void - { - /** @var Post $object */ - $object = Instantiator::default()( - [ - 'title' => 'title', - 'body' => 'body', - 'shortDescription' => 'description', - 'viewCount' => 3, - ], - PostForInstantiate::class - ); - - $this->assertInstanceOf(PostForInstantiate::class, $object); - $this->assertSame('(constructor) title', $object->getTitle()); - $this->assertSame('(constructor) body', $object->getBody()); - $this->assertSame('(constructor) description', $object->getShortDescription()); - $this->assertSame(3, $object->getViewCount()); + public function default_instantiate(): void + { + $object = (new Instantiator())([ + 'propA' => 'A', + 'propB' => 'B', + 'propC' => 'C', + 'propD' => 'D', + ], InstantiatorDummy::class); + + $this->assertSame('A', $object->propA); + $this->assertSame('A', $object->getPropA()); + $this->assertSame('constructor B', $object->getPropB()); + $this->assertSame('constructor C', $object->getPropC()); + $this->assertSame('setter D', $object->getPropD()); } /** * @test */ - public function allows_snake_case_constructor_and_object_properties(): void - { - /** @var Post $object */ - $object = Instantiator::default()( - [ - 'title' => 'title', - 'body' => 'body', - 'short_description' => 'description', - 'view_count' => 3, - ], - PostForInstantiate::class - ); - - $this->assertInstanceOf(PostForInstantiate::class, $object); - $this->assertSame('(constructor) title', $object->getTitle()); - $this->assertSame('(constructor) body', $object->getBody()); - $this->assertSame('(constructor) description', $object->getShortDescription()); - $this->assertSame(3, $object->getViewCount()); + public function can_use_snake_case_attributes(): void + { + $object = (new Instantiator())([ + 'prop_a' => 'A', + 'prop_b' => 'B', + 'prop_c' => 'C', + 'prop_d' => 'D', + ], InstantiatorDummy::class); + + $this->assertSame('A', $object->propA); + $this->assertSame('A', $object->getPropA()); + $this->assertSame('constructor B', $object->getPropB()); + $this->assertSame('constructor C', $object->getPropC()); + $this->assertSame('setter D', $object->getPropD()); } /** * @test */ - public function allows_kebab_case_constructor_and_object_properties(): void - { - /** @var Post $object */ - $object = Instantiator::default()( - [ - 'title' => 'title', - 'body' => 'body', - 'short-description' => 'description', - 'view-count' => 3, - ], - PostForInstantiate::class - ); - - $this->assertInstanceOf(PostForInstantiate::class, $object); - $this->assertSame('(constructor) title', $object->getTitle()); - $this->assertSame('(constructor) body', $object->getBody()); - $this->assertSame('(constructor) description', $object->getShortDescription()); - $this->assertSame(3, $object->getViewCount()); + public function can_use_kebab_case_attributes(): void + { + $object = (new Instantiator())([ + 'prop-a' => 'A', + 'prop-b' => 'B', + 'prop-c' => 'C', + 'prop-d' => 'D', + ], InstantiatorDummy::class); + + $this->assertSame('A', $object->propA); + $this->assertSame('A', $object->getPropA()); + $this->assertSame('constructor B', $object->getPropB()); + $this->assertSame('constructor C', $object->getPropC()); + $this->assertSame('setter D', $object->getPropD()); } /** * @test */ - public function allows_default_constructor_parameters(): void + public function can_leave_off_default_constructor_argument(): void { - /** @var Post $object */ - $object = Instantiator::default()( - [ - 'title' => 'title', - 'body' => 'body', - ], - PostForInstantiate::class - ); + $object = (new Instantiator())([ + 'propB' => 'B', + ], InstantiatorDummy::class); - $this->assertInstanceOf(PostForInstantiate::class, $object); - $this->assertSame('(constructor) title', $object->getTitle()); - $this->assertSame('(constructor) body', $object->getBody()); - $this->assertNull($object->getShortDescription()); - $this->assertSame(0, $object->getViewCount()); + $this->assertSame('constructor B', $object->getPropB()); + $this->assertNull($object->getPropC()); } /** * @test */ - public function allows_extra_attributes_by_default(): void + public function can_instantiate_object_with_private_constructor(): void { - /** @var Post $object */ - $object = Instantiator::default()( - [ - 'title' => 'title', - 'body' => 'body', - 'extra' => 'foo', - ], - PostForInstantiate::class - ); - - $this->assertInstanceOf(PostForInstantiate::class, $object); + $object = (new Instantiator())([ + 'propA' => 'A', + 'propB' => 'B', + 'propC' => 'C', + 'propD' => 'D', + ], PrivateConstructorInstantiatorDummy::class); + + $this->assertSame('A', $object->propA); + $this->assertSame('A', $object->getPropA()); + $this->assertSame('setter B', $object->getPropB()); + $this->assertSame('setter C', $object->getPropC()); + $this->assertSame('setter D', $object->getPropD()); } /** * @test */ - public function strict_mode_does_not_allow_extra_attributes(): void + public function missing_constructor_argument_throws_exception(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage(\sprintf('Class "%s" does not have property "extra"', PostForInstantiate::class)); + $this->expectExceptionMessage('Missing constructor argument "propB" for "Zenstruck\Foundry\Tests\Unit\InstantiatorDummy".'); - Instantiator::default()->strict()( - [ - 'title' => 'title', - 'body' => 'body', - 'extra' => 'foo', - ], - PostForInstantiate::class - ); + (new Instantiator())([], InstantiatorDummy::class); } /** * @test */ - public function default_mode_throws_exception_if_missing_constructor_argument(): void + public function extra_attributes_throws_exception(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage(\sprintf('Missing constructor argument "body" for "%s"', PostForInstantiate::class)); + $this->expectExceptionMessage('Cannot set attribute "extra" for object "Zenstruck\Foundry\Tests\Unit\InstantiatorDummy" (not public and no setter).'); - Instantiator::default()(['title' => 'title'], PostForInstantiate::class); + (new Instantiator())([ + 'propB' => 'B', + 'extra' => 'foo', + ], InstantiatorDummy::class); } /** * @test */ - public function only_constructor_mode_throws_exception_if_missing_constructor_argument(): void + public function can_prefix_extra_attribute_key_with_optional_to_avoid_exception(): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage(\sprintf('Missing constructor argument "body" for "%s"', PostForInstantiate::class)); + $object = (new Instantiator())([ + 'propB' => 'B', + 'optional:extra' => 'foo', + ], InstantiatorDummy::class); + + $this->assertSame('constructor B', $object->getPropB()); + } + + /** + * @test + */ + public function can_always_allow_extra_attributes(): void + { + $object = (new Instantiator())->allowExtraAttributes()([ + 'propB' => 'B', + 'extra' => 'foo', + ], InstantiatorDummy::class); + + $this->assertSame('constructor B', $object->getPropB()); + } - Instantiator::onlyConstructor()(['title' => 'title'], PostForInstantiate::class); + /** + * @test + */ + public function can_disable_constructor(): void + { + $object = (new Instantiator())->withoutConstructor()([ + 'propA' => 'A', + 'propB' => 'B', + 'propC' => 'C', + 'propD' => 'D', + ], InstantiatorDummy::class); + + $this->assertSame('A', $object->propA); + $this->assertSame('A', $object->getPropA()); + $this->assertSame('setter B', $object->getPropB()); + $this->assertSame('setter C', $object->getPropC()); + $this->assertSame('setter D', $object->getPropD()); } /** * @test */ - public function without_constructor_mode_bypasses_constructor(): void + public function prefixing_attribute_key_with_force_sets_the_property_directly(): void { - /** @var Post $object */ - $object = Instantiator::withoutConstructor()( - [ - 'title' => 'title', - ], - PostForInstantiate::class - ); + $object = (new Instantiator())([ + 'propA' => 'A', + 'propB' => 'B', + 'propC' => 'C', + 'force:propD' => 'D', + ], InstantiatorDummy::class); + + $this->assertSame('A', $object->propA); + $this->assertSame('A', $object->getPropA()); + $this->assertSame('constructor B', $object->getPropB()); + $this->assertSame('constructor C', $object->getPropC()); + $this->assertSame('D', $object->getPropD()); + } - $this->assertInstanceOf(PostForInstantiate::class, $object); - $this->assertSame('title', $object->getTitle()); - $this->assertNull($object->getBody()); + /** + * @test + */ + public function prefixing_snake_case_attribute_key_with_force_sets_the_property_directly(): void + { + $object = (new Instantiator())([ + 'prop_a' => 'A', + 'prop_b' => 'B', + 'prop_c' => 'C', + 'force:prop_d' => 'D', + ], InstantiatorDummy::class); + + $this->assertSame('A', $object->propA); + $this->assertSame('A', $object->getPropA()); + $this->assertSame('constructor B', $object->getPropB()); + $this->assertSame('constructor C', $object->getPropC()); + $this->assertSame('D', $object->getPropD()); + } + + /** + * @test + */ + public function prefixing_kebab_case_attribute_key_with_force_sets_the_property_directly(): void + { + $object = (new Instantiator())([ + 'prop-a' => 'A', + 'prop-b' => 'B', + 'prop-c' => 'C', + 'force:prop-d' => 'D', + ], InstantiatorDummy::class); + + $this->assertSame('A', $object->propA); + $this->assertSame('A', $object->getPropA()); + $this->assertSame('constructor B', $object->getPropB()); + $this->assertSame('constructor C', $object->getPropC()); + $this->assertSame('D', $object->getPropD()); } /** * @test */ - public function only_constructor_mode_does_not_set_properties(): void - { - /** @var Post $object */ - $object = Instantiator::onlyConstructor()( - [ - 'title' => 'title', - 'body' => 'body', - 'shortDescription' => 'description', - 'viewCount' => 3, - ], - PostForInstantiate::class - ); - - $this->assertInstanceOf(PostForInstantiate::class, $object); - $this->assertSame('(constructor) title', $object->getTitle()); - $this->assertSame('(constructor) body', $object->getBody()); - $this->assertSame('(constructor) description', $object->getShortDescription()); - $this->assertSame(0, $object->getViewCount()); + public function prefixing_invalid_attribute_key_with_force_throws_exception(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Class "Zenstruck\Foundry\Tests\Unit\InstantiatorDummy" does not have property "extra".'); + + (new Instantiator())([ + 'propB' => 'B', + 'force:extra' => 'foo', + ], InstantiatorDummy::class); } /** * @test */ - public function can_force_set_non_public_properties(): void + public function can_use_force_set_and_get(): void { - $post = new PostForInstantiate('title', 'body'); + $object = new InstantiatorDummy('B'); + + $this->assertNull(Instantiator::forceGet($object, 'propE')); + $this->assertNull(Instantiator::forceGet($object, 'prop_e')); + $this->assertNull(Instantiator::forceGet($object, 'prop-e')); - $this->assertSame('(constructor) title', $post->getTitle()); + Instantiator::forceSet($object, 'propE', 'camel'); + + $this->assertSame('camel', Instantiator::forceGet($object, 'propE')); + $this->assertSame('camel', Instantiator::forceGet($object, 'prop_e')); + $this->assertSame('camel', Instantiator::forceGet($object, 'prop-e')); + + Instantiator::forceSet($object, 'prop_e', 'snake'); + + $this->assertSame('snake', Instantiator::forceGet($object, 'propE')); + $this->assertSame('snake', Instantiator::forceGet($object, 'prop_e')); + $this->assertSame('snake', Instantiator::forceGet($object, 'prop-e')); + + Instantiator::forceSet($object, 'prop-e', 'kebab'); + + $this->assertSame('kebab', Instantiator::forceGet($object, 'propE')); + $this->assertSame('kebab', Instantiator::forceGet($object, 'prop_e')); + $this->assertSame('kebab', Instantiator::forceGet($object, 'prop-e')); + } - Instantiator::default()->forceSet($post, 'title', 'new title'); - Instantiator::default()->forceSet($post, 'missing', 'foobar'); + /** + * @test + */ + public function force_set_throws_exception_for_invalid_property(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Class "Zenstruck\Foundry\Tests\Unit\InstantiatorDummy" does not have property "invalid".'); - $this->assertSame('new title', $post->getTitle()); + Instantiator::forceSet(new InstantiatorDummy('B'), 'invalid', 'value'); } /** * @test */ - public function force_set_with_missing_attribute_and_strict_mode_throws_exception(): void + public function force_get_throws_exception_for_invalid_property(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage(\sprintf('Class "%s" does not have property "missing"', PostForInstantiate::class)); + $this->expectExceptionMessage('Class "Zenstruck\Foundry\Tests\Unit\InstantiatorDummy" does not have property "invalid".'); - Instantiator::default()->strict()->forceSet(new PostForInstantiate('title', 'body'), 'missing', 'foobar'); + Instantiator::forceGet(new InstantiatorDummy('B'), 'invalid'); } /** * @test */ - public function can_force_get_non_public_properties(): void + public function can_use_always_force_mode(): void { - $post = new PostForInstantiate('title', 'body'); + $object = (new Instantiator())->alwaysForceProperties()([ + 'propA' => 'A', + 'propB' => 'B', + 'propC' => 'C', + 'propD' => 'D', + ], InstantiatorDummy::class); + + $this->assertSame('A', $object->propA); + $this->assertSame('A', $object->getPropA()); + $this->assertSame('constructor B', $object->getPropB()); + $this->assertSame('constructor C', $object->getPropC()); + $this->assertSame('D', $object->getPropD()); + } - $this->assertSame('(constructor) title', Instantiator::default()->forceGet($post, 'title')); - $this->assertNull(Instantiator::default()->forceGet($post, 'missing')); + /** + * @test + */ + public function can_use_always_force_mode_allows_snake_case(): void + { + $object = (new Instantiator())->alwaysForceProperties()([ + 'prop_a' => 'A', + 'prop_b' => 'B', + 'prop_c' => 'C', + 'prop_d' => 'D', + ], InstantiatorDummy::class); + + $this->assertSame('A', $object->propA); + $this->assertSame('A', $object->getPropA()); + $this->assertSame('constructor B', $object->getPropB()); + $this->assertSame('constructor C', $object->getPropC()); + $this->assertSame('D', $object->getPropD()); + } + + /** + * @test + */ + public function can_use_always_force_mode_allows_kebab_case(): void + { + $object = (new Instantiator())->alwaysForceProperties()([ + 'prop-a' => 'A', + 'prop-b' => 'B', + 'prop-c' => 'C', + 'prop-d' => 'D', + ], InstantiatorDummy::class); + + $this->assertSame('A', $object->propA); + $this->assertSame('A', $object->getPropA()); + $this->assertSame('constructor B', $object->getPropB()); + $this->assertSame('constructor C', $object->getPropC()); + $this->assertSame('D', $object->getPropD()); } /** * @test */ - public function force_get_with_missing_attribute_and_strict_mode_throws_exception(): void + public function always_force_mode_throws_exception_for_extra_attributes(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage(\sprintf('Class "%s" does not have property "missing"', PostForInstantiate::class)); + $this->expectExceptionMessage('Class "Zenstruck\Foundry\Tests\Unit\InstantiatorDummy" does not have property "extra".'); + + (new Instantiator())->alwaysForceProperties()([ + 'propB' => 'B', + 'extra' => 'foo', + ], InstantiatorDummy::class); + } + + /** + * @test + */ + public function always_force_mode_allows_optional_attribute_name_prefix(): void + { + $object = (new Instantiator())->alwaysForceProperties()([ + 'propB' => 'B', + 'propD' => 'D', + 'optional:extra' => 'foo', + ], InstantiatorDummy::class); + + $this->assertSame('D', $object->getPropD()); + } + + /** + * @test + */ + public function always_force_mode_with_allow_extra_attributes_mode(): void + { + $object = (new Instantiator())->allowExtraAttributes()->alwaysForceProperties()([ + 'propB' => 'B', + 'propD' => 'D', + 'extra' => 'foo', + ], InstantiatorDummy::class); - Instantiator::default()->strict()->forceGet(new PostForInstantiate('title', 'body'), 'missing'); + $this->assertSame('D', $object->getPropD()); + } + + /** + * @test + */ + public function always_force_mode_can_set_parent_class_properties(): void + { + $object = (new Instantiator())->alwaysForceProperties()([ + 'propA' => 'A', + 'propB' => 'B', + 'propC' => 'C', + 'propD' => 'D', + 'propE' => 'E', + ], ExtendedInstantiatorDummy::class); + + $this->assertSame('A', $object->propA); + $this->assertSame('A', $object->getPropA()); + $this->assertSame('constructor B', $object->getPropB()); + $this->assertSame('constructor C', $object->getPropC()); + $this->assertSame('D', $object->getPropD()); + $this->assertSame('E', Instantiator::forceGet($object, 'propE')); } } -class PostForInstantiate extends Post +class InstantiatorDummy { - public function __construct(string $title, string $body, string $shortDescription = null) + public $propA; + public $propD; + private $propB; + private $propC; + private $propE; + + public function __construct($propB, $propC = null) { - $title = '(constructor) '.$title; - $body = '(constructor) '.$body; + $this->propB = 'constructor '.$propB; - if ($shortDescription) { - $shortDescription = '(constructor) '.$shortDescription; + if ($propC) { + $this->propC = 'constructor '.$propC; } + } + + public function getPropA() + { + return $this->propA; + } + + public function getPropB() + { + return $this->propB; + } + + public function setPropB($propB) + { + $this->propB = 'setter '.$propB; + } + + public function getPropC() + { + return $this->propC; + } + + public function setPropC($propC) + { + $this->propC = 'setter '.$propC; + } + + public function getPropD() + { + return $this->propD; + } - parent::__construct($title, $body, $shortDescription); + public function setPropD($propD) + { + $this->propD = 'setter '.$propD; + } +} + +class ExtendedInstantiatorDummy extends InstantiatorDummy +{ +} + +class PrivateConstructorInstantiatorDummy extends InstantiatorDummy +{ + private function __construct() + { + parent::__construct('B', 'C'); } } diff --git a/tests/Unit/ModelFactoryTest.php b/tests/Unit/ModelFactoryTest.php new file mode 100644 index 000000000..ce715d624 --- /dev/null +++ b/tests/Unit/ModelFactoryTest.php @@ -0,0 +1,49 @@ + + */ +final class ModelFactoryTest extends UnitTestCase +{ + /** + * @test + */ + public function can_set_states_with_method(): void + { + $this->assertFalse(PostFactory::new()->withoutPersisting()->create()->isPublished()); + $this->assertTrue(PostFactory::new()->published()->withoutPersisting()->create()->isPublished()); + } + + /** + * @test + */ + public function can_set_state_via_new(): void + { + $this->assertFalse(PostFactory::new()->withoutPersisting()->create()->isPublished()); + $this->assertTrue(PostFactory::new('published')->withoutPersisting()->create()->isPublished()); + } + + /** + * @test + */ + public function can_instantiate(): void + { + $this->assertSame('title', PostFactory::new()->withoutPersisting()->create(['title' => 'title'])->getTitle()); + } + + /** + * @test + */ + public function can_instantiate_many(): void + { + $objects = PostFactory::new()->withoutPersisting()->createMany(2, ['title' => 'title']); + + $this->assertCount(2, $objects); + $this->assertSame('title', $objects[0]->getTitle()); + } +} diff --git a/tests/Unit/PersistenceManagerTest.php b/tests/Unit/PersistenceManagerTest.php deleted file mode 100644 index 3c8105967..000000000 --- a/tests/Unit/PersistenceManagerTest.php +++ /dev/null @@ -1,185 +0,0 @@ - - */ -final class PersistenceManagerTest extends TestCase -{ - use ResetGlobals; - - /** - * @test - */ - public function can_get_repository_for_object(): void - { - $registry = $this->createMock(ManagerRegistry::class); - $registry - ->expects($this->once()) - ->method('getRepository') - ->with(Category::class) - ->willReturn($this->createMock(ObjectRepository::class)) - ; - - PersistenceManager::register($registry); - - $this->assertInstanceOf(RepositoryProxy::class, PersistenceManager::repositoryFor(new Category())); - } - - /** - * @test - */ - public function can_get_repository_for_class(): void - { - $registry = $this->createMock(ManagerRegistry::class); - $registry - ->expects($this->once()) - ->method('getRepository') - ->with(Category::class) - ->willReturn($this->createMock(ObjectRepository::class)) - ; - - PersistenceManager::register($registry); - - $this->assertInstanceOf(RepositoryProxy::class, PersistenceManager::repositoryFor(Category::class)); - } - - /** - * @test - */ - public function can_get_repository_for_object_proxy(): void - { - $registry = $this->createMock(ManagerRegistry::class); - $registry - ->expects($this->once()) - ->method('getRepository') - ->with(Category::class) - ->willReturn($this->createMock(ObjectRepository::class)) - ; - - PersistenceManager::register($registry); - - $proxy = (new Proxy(new Category()))->withoutAutoRefresh(); - - $this->assertInstanceOf(RepositoryProxy::class, PersistenceManager::repositoryFor($proxy)); - } - - /** - * @test - */ - public function can_disable_proxying_when_getting_repository(): void - { - $registry = $this->createMock(ManagerRegistry::class); - $registry - ->expects($this->once()) - ->method('getRepository') - ->with(Category::class) - ->willReturn($this->createMock(ObjectRepository::class)) - ; - - PersistenceManager::register($registry); - - $repository = PersistenceManager::repositoryFor(new Category(), false); - - $this->assertInstanceOf(ObjectRepository::class, $repository); - $this->assertNotInstanceOf(RepositoryProxy::class, $repository); - } - - /** - * @test - */ - public function can_persist_object(): void - { - $category = new Category(); - - $manager = $this->createMock(ObjectManager::class); - $manager->expects($this->once())->method('persist')->with($category); - $manager->expects($this->once())->method('flush'); - - $registry = $this->createMock(ManagerRegistry::class); - $registry - ->expects($this->once()) - ->method('getManagerForClass') - ->with(Category::class) - ->willReturn($manager) - ; - - PersistenceManager::register($registry); - - $object = PersistenceManager::persist($category); - - $this->assertInstanceOf(Proxy::class, $object); - $this->assertSame($category, $object->withoutAutoRefresh()->object()); - } - - /** - * @test - */ - public function can_persist_object_without_proxy(): void - { - $category = new Category(); - - $manager = $this->createMock(ObjectManager::class); - $manager->expects($this->once())->method('persist')->with($category); - $manager->expects($this->once())->method('flush'); - - $registry = $this->createMock(ManagerRegistry::class); - $registry - ->expects($this->once()) - ->method('getManagerForClass') - ->with(Category::class) - ->willReturn($manager) - ; - - PersistenceManager::register($registry); - - $object = PersistenceManager::persist($category, false); - - $this->assertSame($category, $object); - } - - /** - * @test - */ - public function proxying_a_proxy_returns_the_proxy(): void - { - $proxy = PersistenceManager::proxy(new Category()); - - $this->assertSame($proxy, PersistenceManager::proxy($proxy)); - } - - /** - * @test - */ - public function exception_thrown_if_no_manager_registry_registered(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('ManagerRegistry not registered...'); - - PersistenceManager::objectManagerFor(Category::class); - } - - /** - * @test - */ - public function exception_thrown_if_manager_does_not_manage_object(): void - { - PersistenceManager::register($this->createMock(ManagerRegistry::class)); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage(\sprintf('No object manager registered for "%s".', Category::class)); - - PersistenceManager::objectManagerFor(Category::class); - } -} diff --git a/tests/Unit/ProxyTest.php b/tests/Unit/ProxyTest.php index a422b59c8..d248f2f5b 100644 --- a/tests/Unit/ProxyTest.php +++ b/tests/Unit/ProxyTest.php @@ -2,25 +2,17 @@ namespace Zenstruck\Foundry\Tests\Unit; -use PHPUnit\Framework\TestCase; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectManager; use Zenstruck\Foundry\Proxy; use Zenstruck\Foundry\Tests\Fixtures\Entity\Category; -use Zenstruck\Foundry\Tests\ResetGlobals; +use Zenstruck\Foundry\Tests\UnitTestCase; /** * @author Kevin Bond */ -final class ProxyTest extends TestCase +final class ProxyTest extends UnitTestCase { - use ResetGlobals; - - protected function setUp(): void - { - parent::setUp(); - - Proxy::autoRefreshByDefault(false); - } - /** * @test */ @@ -56,4 +48,66 @@ public function can_access_wrapped_objects_properties(): void $this->assertFalse(isset($proxy->property)); } + + /** + * @test + */ + public function cannot_refresh_unpersisted_proxy(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot refresh unpersisted object (Zenstruck\Foundry\Tests\Fixtures\Entity\Category).'); + + (new Proxy(new Category()))->refresh(); + } + + /** + * @test + */ + public function saving_unpersisted_proxy_changes_it_to_a_persisted_proxy(): void + { + $registry = $this->createMock(ManagerRegistry::class); + $registry + ->expects($this->exactly(2)) + ->method('getManagerForClass') + ->with(Category::class) + ->willReturn($this->createMock(ObjectManager::class)) + ; + + $this->configuration->setManagerRegistry($registry); + + $category = new Proxy(new Category()); + + $this->assertFalse($category->isPersisted()); + + $category->save(); + + $this->assertTrue($category->isPersisted()); + } + + /** + * @test + */ + public function can_use_without_auto_refresh_with_proxy_or_object_typehint(): void + { + $proxy = new Proxy(new Category()); + $calls = 0; + + $proxy + ->withoutAutoRefresh(static function(Proxy $proxy) use (&$calls) { + ++$calls; + }) + ->withoutAutoRefresh(static function(Category $category) use (&$calls) { + ++$calls; + }) + ->withoutAutoRefresh(function($proxy) use (&$calls) { + $this->assertInstanceOf(Proxy::class, $proxy); + ++$calls; + }) + ->withoutAutoRefresh(static function() use (&$calls) { + ++$calls; + }) + ; + + $this->assertSame(4, $calls); + } } diff --git a/tests/UnitTestCase.php b/tests/UnitTestCase.php new file mode 100644 index 000000000..ec71eedf3 --- /dev/null +++ b/tests/UnitTestCase.php @@ -0,0 +1,28 @@ + + */ +abstract class UnitTestCase extends TestCase +{ + /** @var Configuration|null */ + protected $configuration; + + /** + * @before + */ + public function setUpFoundry(): void + { + $this->configuration = new Configuration($this->createMock(ManagerRegistry::class), new StoryManager([])); + + Factory::boot($this->configuration); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 91ddd9d54..2d493ad63 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,10 +1,17 @@ remove(\sys_get_temp_dir().'/zenstruck-foundry'); + +if (!\getenv('USE_FOUNDRY_BUNDLE')) { + TestState::withoutBundle(); +} + +TestState::addGlobalState(static function() { TagStory::load(); });