From b145059c812f614bef23043870641aa0797579d9 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Wed, 28 Jul 2021 09:55:09 -0400 Subject: [PATCH 01/12] [minor] psalm fix (#180) --- src/Bundle/Maker/MakeStory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bundle/Maker/MakeStory.php b/src/Bundle/Maker/MakeStory.php index ff86c3132..520b9527c 100644 --- a/src/Bundle/Maker/MakeStory.php +++ b/src/Bundle/Maker/MakeStory.php @@ -51,7 +51,7 @@ public function interact(InputInterface $input, ConsoleStyle $io, Command $comma } $argument = $command->getDefinition()->getArgument('name'); - $value = $io->ask($argument->getDescription(), $argument->getDefault(), [Validator::class, 'notBlank']); + $value = $io->ask($argument->getDescription(), null, [Validator::class, 'notBlank']); $input->setArgument($argument->getName(), $value); } From 2517f54f582954f73287c62bb285098098db9103 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Mon, 16 Aug 2021 09:32:58 -0400 Subject: [PATCH 02/12] [minor] change Instantiator::$forceProperties type-hint (#183) --- src/Instantiator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Instantiator.php b/src/Instantiator.php index 0ea136e1e..d71f149d0 100644 --- a/src/Instantiator.php +++ b/src/Instantiator.php @@ -26,7 +26,7 @@ final class Instantiator /** @var bool */ private $alwaysForceProperties = false; - /** @var array */ + /** @var string[] */ private $forceProperties = []; public function __invoke(array $attributes, string $class): object From a6f6413e2b57ac8ad0dd9dc94e254978fe4600df Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Fri, 20 Aug 2021 03:46:40 -0700 Subject: [PATCH 03/12] [minor] Added .editorconfig to sync up styles (#186) --- .editorconfig | 12 ++++++++++++ README.md | 20 ++++++++++---------- 2 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..402bd2f10 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.yml] +indent_size = 2 diff --git a/README.md b/README.md index 80b0befd1..6a08ef5e3 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ class Post * @ORM\Column(type="text", nullable=true) */ private $body; - + /** * @ORM\Column(type="datetime") */ @@ -397,7 +397,7 @@ $posts = PostFactory::new(['title' => 'Post A']) 'body' => 'Post Body...', // CategoryFactory will be used to create a new Category for each Post - 'category' => CategoryFactory::new(['name' => 'php']), + 'category' => CategoryFactory::new(['name' => 'php']), ]) ->withAttributes([ // Proxies are automatically converted to their wrapped object @@ -481,7 +481,7 @@ PostFactory::new() // $object is the persisted Post object // $attributes contains the attributes used to instantiate the object and any extras }) - + // multiple events are allowed ->beforeInstantiate(function($attributes) { return $attributes; }) ->afterInstantiate(function() {}) @@ -524,7 +524,7 @@ final class PostFactory extends ModelFactory return new Post(); // custom instantiation for this factory }) ->afterPersist(function () {}) // default event for this factory - ; + ; } } ``` @@ -662,7 +662,7 @@ protected function getDefaults(): array 'post' => PostFactory::new()->published(), // NOT RECOMMENDED - will potentially result in extra unintended Posts - 'post' => PostFactory::createOne(), + 'post' => PostFactory::createOne(), 'post' => PostFactory::new()->published()->create(), ]; } @@ -964,7 +964,7 @@ Foundry allows each individual test to fully follow the [AAA](https://www.thephi ("Arrange", "Act", "Assert") testing pattern. You create your fixtures using "factories" at the beginning of each test. You only create fixtures that are applicable for the test. Additionally, these fixtures are created with only the attributes required for the test - attributes that are not applicable are filled with random data. The created fixture -objects are wrapped in a "proxy" that helps with pre and post assertions. +objects are wrapped in a "proxy" that helps with pre and post assertions. Let's look at an example: @@ -978,7 +978,7 @@ public function test_can_post_a_comment(): void 'slug' => 'post-a' // This test only requires the slug field - all other fields are random data ]) ; - + // 1a. "Pre-Assertions" $this->assertCount(0, $post->getComments()); @@ -1038,7 +1038,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class MyTest extends WebTestCase { use ResetDatabase, Factories; - + // ... } ``` @@ -1370,7 +1370,7 @@ these tests to be unnecessarily slow. You can improve the speed by reducing the class UserFactory extends ModelFactory { public const DEFAULT_PASSWORD = '1234'; // the password used to create the pre-encoded version below - + protected function getDefaults(): array { return [ @@ -1412,7 +1412,7 @@ class MyUnitTest extends TestCase } ``` -**NOTE**: [Factories as Services](#factories-as-services) and [Stories as Services](#stories-as-services) with required +**NOTE**: [Factories as Services](#factories-as-services) and [Stories as Services](#stories-as-services) with required constructor arguments are not usable in non-Kernel tests. The container is not available to resolve their dependencies. The easiest work-around is to make the test an instance of `Symfony\Bundle\FrameworkBundle\Test\KernelTestCase` so the container is available. From 1647e1bf342fe0f66e8bc4ea442a3b370c446033 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Fri, 20 Aug 2021 14:08:36 -0400 Subject: [PATCH 04/12] [bug] ensure legacy test works as expected (#187) --- tests/Unit/ModelFactoryTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Unit/ModelFactoryTest.php b/tests/Unit/ModelFactoryTest.php index eaf21dc8a..707393d58 100644 --- a/tests/Unit/ModelFactoryTest.php +++ b/tests/Unit/ModelFactoryTest.php @@ -46,10 +46,11 @@ public function can_instantiate(): void */ public function can_instantiate_many_legacy(): void { - $objects = PostFactory::createMany(2, ['title' => 'title']); + $objects = PostFactory::new(['body' => 'body'])->createMany(2, ['title' => 'title']); $this->assertCount(2, $objects); $this->assertSame('title', $objects[0]->getTitle()); + $this->assertSame('body', $objects[1]->getBody()); } /** From 468e80b0dc776f05540a88ffdbddbf98e7fc8a7d Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Fri, 20 Aug 2021 14:51:43 -0400 Subject: [PATCH 05/12] [minor] add missing ->expectDeprecation() to legacy tests (#188) --- tests/Functional/RepositoryProxyTest.php | 27 +++++++++++++++++++++++- tests/Unit/ModelFactoryTest.php | 5 ++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/Functional/RepositoryProxyTest.php b/tests/Functional/RepositoryProxyTest.php index f986a4394..e8b2f2c2c 100644 --- a/tests/Functional/RepositoryProxyTest.php +++ b/tests/Functional/RepositoryProxyTest.php @@ -31,12 +31,37 @@ public function functions_calls_are_passed_to_underlying_repository(): void /** * @test - * @group legacy */ public function assertions(): void { $repository = repository(Category::class); + $repository->assert()->empty(); + + CategoryFactory::createMany(2); + + $repository->assert()->count(2); + $repository->assert()->countGreaterThan(1); + $repository->assert()->countGreaterThanOrEqual(2); + $repository->assert()->countLessThan(3); + $repository->assert()->countLessThanOrEqual(2); + } + + /** + * @test + * @group legacy + */ + public function assertions_legacy(): void + { + $repository = repository(Category::class); + + $this->expectDeprecation('Since zenstruck\foundry 1.8.0: Using RepositoryProxy::assertEmpty() is deprecated, use RepositoryProxy::assert()->empty().'); + $this->expectDeprecation('Since zenstruck\foundry 1.8.0: Using RepositoryProxy::assertCount() is deprecated, use RepositoryProxy::assert()->count().'); + $this->expectDeprecation('Since zenstruck\foundry 1.8.0: Using RepositoryProxy::assertCountGreaterThan() is deprecated, use RepositoryProxy::assert()->countGreaterThan().'); + $this->expectDeprecation('Since zenstruck\foundry 1.8.0: Using RepositoryProxy::assertCountGreaterThanOrEqual() is deprecated, use RepositoryProxy::assert()->countGreaterThanOrEqual().'); + $this->expectDeprecation('Since zenstruck\foundry 1.8.0: Using RepositoryProxy::assertCountLessThan() is deprecated, use RepositoryProxy::assert()->countLessThan().'); + $this->expectDeprecation('Since zenstruck\foundry 1.8.0: Using RepositoryProxy::assertCountLessThanOrEqual() is deprecated, use RepositoryProxy::assert()->countLessThanOrEqual().'); + $repository->assertEmpty(); CategoryFactory::createMany(2); diff --git a/tests/Unit/ModelFactoryTest.php b/tests/Unit/ModelFactoryTest.php index 707393d58..366548015 100644 --- a/tests/Unit/ModelFactoryTest.php +++ b/tests/Unit/ModelFactoryTest.php @@ -3,6 +3,7 @@ namespace Zenstruck\Foundry\Tests\Unit; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Tests\Fixtures\Factories\PostFactory; @@ -11,7 +12,7 @@ */ final class ModelFactoryTest extends TestCase { - use Factories; + use ExpectDeprecationTrait, Factories; /** * @test @@ -46,6 +47,8 @@ public function can_instantiate(): void */ public function can_instantiate_many_legacy(): void { + $this->expectDeprecation(\sprintf('Since zenstruck/foundry 1.7: Calling instance method "%1$s::createMany()" is deprecated and will be removed in 2.0, use the static "%1$s:createMany()" method instead.', PostFactory::class)); + $objects = PostFactory::new(['body' => 'body'])->createMany(2, ['title' => 'title']); $this->assertCount(2, $objects); From 0416dc4b6cade0e4b7afb464f6426caf1abc45ef Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Mon, 23 Aug 2021 04:07:11 -0700 Subject: [PATCH 06/12] [minor] Be able to remove most of method annotations on user factories (#185) PHPStorm 2021.2+ has rudimentary generic annotation support. --- README.md | 4 +++- src/AnonymousFactory.php | 6 ++---- src/Factory.php | 7 +++---- src/FactoryCollection.php | 4 ++-- src/ModelFactory.php | 35 ++++++++++++++++++++++++++++------- src/Proxy.php | 4 ++-- src/RepositoryProxy.php | 10 +++++----- src/functions.php | 6 +++--- 8 files changed, 48 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 6a08ef5e3..2e9806777 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,8 @@ use Zenstruck\Foundry\ModelFactory; use Zenstruck\Foundry\Proxy; /** + * @extends ModelFactory + * * @method static Post|Proxy createOne(array $attributes = []) * @method static Post[]|Proxy[] createMany(int $number, $attributes = []) * @method static Post|Proxy find($criteria) @@ -209,7 +211,7 @@ use Zenstruck\Foundry\Proxy; * @method static Post[]|Proxy[] randomSet(int $number, array $attributes = [])) * @method static Post[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])) * @method static PostRepository|RepositoryProxy repository() - * @method Post|Proxy create($attributes = []) + * @method Post|Proxy create(array|callable $attributes = []) */ final class PostFactory extends ModelFactory { diff --git a/src/AnonymousFactory.php b/src/AnonymousFactory.php index 4625ba92f..de255628e 100644 --- a/src/AnonymousFactory.php +++ b/src/AnonymousFactory.php @@ -22,8 +22,7 @@ public static function new(string $class, $defaultAttributes = []): self * Try and find existing object for the given $attributes. If not found, * instantiate and persist. * - * @return Proxy|object - * + * @return Proxy&TModel * @psalm-return Proxy */ public function findOrCreate(array $attributes): Proxy @@ -74,8 +73,7 @@ public function random(array $attributes = []): Proxy /** * Fetch one random object and create a new object if none exists. * - * @return Proxy|object - * + * @return Proxy&TModel * @psalm-return Proxy */ public function randomOrCreate(array $attributes = []): Proxy diff --git a/src/Factory.php b/src/Factory.php index cf6680b0b..dd3bf88d3 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -5,7 +5,7 @@ use Faker; /** - * @template TObject as object + * @template TObject of object * @abstract * * @author Kevin Bond @@ -68,8 +68,7 @@ public function __call(string $name, array $arguments) /** * @param array|callable $attributes * - * @return Proxy|object - * + * @return Proxy&TObject * @psalm-return Proxy */ final public function create($attributes = []): Proxy @@ -119,7 +118,7 @@ function($value) { /** * @see FactoryCollection::__construct() * - * @psalm-return FactoryCollection + * @return FactoryCollection */ final public function many(int $min, ?int $max = null): FactoryCollection { diff --git a/src/FactoryCollection.php b/src/FactoryCollection.php index dec38dd40..3da9d0a05 100644 --- a/src/FactoryCollection.php +++ b/src/FactoryCollection.php @@ -3,7 +3,7 @@ namespace Zenstruck\Foundry; /** - * @template TObject as object + * @template TObject of object * * @author Kevin Bond */ @@ -37,7 +37,7 @@ public function __construct(Factory $factory, int $min, ?int $max = null) /** * @param array|callable $attributes * - * @return Proxy[]|object[] + * @return list> * * @psalm-suppress InvalidReturnType * @psalm-return list> diff --git a/src/ModelFactory.php b/src/ModelFactory.php index 752442ccb..8e8bc89f0 100644 --- a/src/ModelFactory.php +++ b/src/ModelFactory.php @@ -6,7 +6,7 @@ * @template TModel of object * @template-extends Factory * - * @method static Proxy[]|object[] createMany(int $number, array|callable $attributes = []) + * @method static Proxy[]|TModel[] createMany(int $number, array|callable $attributes = []) * @psalm-method static list> createMany(int $number, array|callable $attributes = []) * * @author Kevin Bond @@ -66,8 +66,7 @@ final public static function new($defaultAttributes = [], string ...$states): se /** * A shortcut to create a single model without states. * - * @return Proxy|object - * + * @return Proxy&TModel * @psalm-return Proxy */ final public static function createOne(array $attributes = []): Proxy @@ -79,8 +78,7 @@ final public static function createOne(array $attributes = []): Proxy * Try and find existing object for the given $attributes. If not found, * instantiate and persist. * - * @return Proxy|object - * + * @return Proxy&TModel * @psalm-return Proxy */ final public static function findOrCreate(array $attributes): Proxy @@ -95,6 +93,9 @@ final public static function findOrCreate(array $attributes): Proxy /** * @see RepositoryProxy::first() * + * @return Proxy&TModel + * @psalm-return Proxy + * * @throws \RuntimeException If no entities exist */ final public static function first(string $sortedField = 'id'): Proxy @@ -109,6 +110,9 @@ final public static function first(string $sortedField = 'id'): Proxy /** * @see RepositoryProxy::last() * + * @return Proxy&TModel + * @psalm-return Proxy + * * @throws \RuntimeException If no entities exist */ final public static function last(string $sortedField = 'id'): Proxy @@ -122,6 +126,9 @@ final public static function last(string $sortedField = 'id'): Proxy /** * @see RepositoryProxy::random() + * + * @return Proxy&TModel + * @psalm-return Proxy */ final public static function random(array $attributes = []): Proxy { @@ -131,8 +138,7 @@ final public static function random(array $attributes = []): Proxy /** * Fetch one random object and create a new object if none exists. * - * @return Proxy|object - * + * @return Proxy&TModel * @psalm-return Proxy */ final public static function randomOrCreate(array $attributes = []): Proxy @@ -146,6 +152,9 @@ final public static function randomOrCreate(array $attributes = []): Proxy /** * @see RepositoryProxy::randomSet() + * + * @return list> + * @psalm-return list> */ final public static function randomSet(int $number, array $attributes = []): array { @@ -154,6 +163,9 @@ final public static function randomSet(int $number, array $attributes = []): arr /** * @see RepositoryProxy::randomRange() + * + * @return list> + * @psalm-return list> */ final public static function randomRange(int $min, int $max, array $attributes = []): array { @@ -178,6 +190,9 @@ final public static function truncate(): void /** * @see RepositoryProxy::findAll() + * + * @return list> + * @psalm-return list> */ final public static function all(): array { @@ -187,6 +202,9 @@ final public static function all(): array /** * @see RepositoryProxy::find() * + * @return Proxy&TModel + * @psalm-return Proxy + * * @throws \RuntimeException If no entity found */ final public static function find($criteria): Proxy @@ -200,6 +218,9 @@ final public static function find($criteria): Proxy /** * @see RepositoryProxy::findBy() + * + * @return list> + * @psalm-return list> */ final public static function findBy(array $attributes): array { diff --git a/src/Proxy.php b/src/Proxy.php index fb2d77bb9..b42e8ba33 100644 --- a/src/Proxy.php +++ b/src/Proxy.php @@ -87,7 +87,7 @@ public function __toString(): string /** * @internal * - * @template TObject as object + * @template TObject of object * @psalm-param TObject $object * @psalm-return Proxy */ @@ -105,7 +105,7 @@ public function isPersisted(): bool } /** - * @psalm-return TProxiedObject + * @return TProxiedObject */ public function object(): object { diff --git a/src/RepositoryProxy.php b/src/RepositoryProxy.php index 35d437ce5..6c039e00a 100644 --- a/src/RepositoryProxy.php +++ b/src/RepositoryProxy.php @@ -164,7 +164,7 @@ public function assertNotExists($criteria, string $message = ''): self } /** - * @return Proxy|object|null + * @return Proxy&TProxiedObject|null * * @psalm-return Proxy|null */ @@ -174,7 +174,7 @@ public function first(string $sortedField = 'id'): ?Proxy } /** - * @return Proxy|object|null + * @return Proxy&TProxiedObject|null * * @psalm-return Proxy|null */ @@ -208,7 +208,7 @@ public function truncate(): void * * @param array $attributes The findBy criteria * - * @return Proxy|object + * @return Proxy&TProxiedObject * * @throws \RuntimeException if no objects are persisted * @@ -276,7 +276,7 @@ public function randomRange(int $min, int $max, array $attributes = []): array /** * @param object|array|mixed $criteria * - * @return Proxy|object|null + * @return Proxy&TProxiedObject|null * * @psalm-param Proxy|array|mixed $criteria * @psalm-return Proxy|null @@ -314,7 +314,7 @@ public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $ /** * @param array|null $orderBy Some ObjectRepository's (ie Doctrine\ORM\EntityRepository) add this optional parameter * - * @return Proxy|object|null + * @return Proxy&TProxiedObject|null * * @throws \RuntimeException if the wrapped ObjectRepository does not have the $orderBy parameter * diff --git a/src/functions.php b/src/functions.php index 580b4d2a6..ee0fcc440 100644 --- a/src/functions.php +++ b/src/functions.php @@ -7,7 +7,7 @@ /** * @see Factory::__construct() * - * @template TObject as object + * @template TObject of object * @psalm-param class-string $class * @psalm-return AnonymousFactory */ @@ -19,7 +19,7 @@ function factory(string $class, $defaultAttributes = []): AnonymousFactory /** * @see Factory::create() * - * @return Proxy|object + * @return Proxy&TObject * * @template TObject of object * @psalm-param class-string $class @@ -47,7 +47,7 @@ function create_many(int $number, string $class, $attributes = []): array /** * Instantiate object without persisting. * - * @return Proxy|object "unpersisted" Proxy wrapping the instantiated object + * @return Proxy&TObject "unpersisted" Proxy wrapping the instantiated object * * @template TObject of object * @psalm-param class-string $class From 1faf97d670e8876d33da92fff14df979b25eb8f1 Mon Sep 17 00:00:00 2001 From: Mathieu Piot Date: Mon, 23 Aug 2021 13:40:30 +0200 Subject: [PATCH 07/12] [feature] persisting factories respect cascade persist (#181) --- src/Factory.php | 79 ++++++++- tests/Fixtures/Entity/Cascade/Brand.php | 56 +++++++ tests/Fixtures/Entity/Cascade/Category.php | 56 +++++++ tests/Fixtures/Entity/Cascade/Image.php | 39 +++++ tests/Fixtures/Entity/Cascade/Product.php | 151 ++++++++++++++++++ tests/Fixtures/Entity/Cascade/Review.php | 54 +++++++ tests/Fixtures/Entity/Cascade/Tag.php | 56 +++++++ tests/Fixtures/Entity/Cascade/Variant.php | 69 ++++++++ .../Migrations/Version20210820131815.php | 41 +++++ .../Functional/FactoryDoctrineCascadeTest.php | 135 ++++++++++++++++ 10 files changed, 733 insertions(+), 3 deletions(-) create mode 100644 tests/Fixtures/Entity/Cascade/Brand.php create mode 100644 tests/Fixtures/Entity/Cascade/Category.php create mode 100644 tests/Fixtures/Entity/Cascade/Image.php create mode 100644 tests/Fixtures/Entity/Cascade/Product.php create mode 100644 tests/Fixtures/Entity/Cascade/Review.php create mode 100644 tests/Fixtures/Entity/Cascade/Tag.php create mode 100644 tests/Fixtures/Entity/Cascade/Variant.php create mode 100644 tests/Fixtures/Migrations/Version20210820131815.php create mode 100644 tests/Functional/FactoryDoctrineCascadeTest.php diff --git a/src/Factory.php b/src/Factory.php index dd3bf88d3..2ab4266fd 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -2,6 +2,7 @@ namespace Zenstruck\Foundry; +use Doctrine\ORM\Mapping\ClassMetadataInfo; use Faker; /** @@ -27,6 +28,9 @@ class Factory /** @var bool */ private $persist = true; + /** @var bool */ + private $cascadePersist = false; + /** @var array */ private $attributeSet = []; @@ -104,7 +108,7 @@ function($value) { $proxy = new Proxy($object); - if (!$this->isPersisting()) { + if (!$this->isPersisting() || true === $this->cascadePersist) { return $proxy; } @@ -301,6 +305,12 @@ function($value) { $value = $value->withoutPersisting(); } + // Check if the attribute is cascade persist + if (self::configuration()->hasManagerRegistry()) { + $relationField = $this->relationshipField($value); + $value->cascadePersist = $this->hasCascadePersist($value, $relationField); + } + return $value->create()->object(); } @@ -315,7 +325,10 @@ private static function normalizeObject(object $object): object private function normalizeCollection(FactoryCollection $collection): array { - if ($this->isPersisting() && $field = $this->inverseRelationshipField($collection->factory())) { + $field = $this->inverseRelationshipField($collection->factory()); + $cascadePersist = $this->hasCascadePersist($collection->factory(), $field); + + if ($this->isPersisting() && $field && false === $cascadePersist) { $this->afterPersist[] = static function(Proxy $proxy) use ($collection, $field) { $collection->create([$field => $proxy]); $proxy->refresh(); @@ -325,7 +338,38 @@ private function normalizeCollection(FactoryCollection $collection): array return []; } - return $collection->all(); + return \array_map( + function(self $factory) { + $factory->cascadePersist = $this->cascadePersist; + + return $factory; + }, + $collection->all() + ); + } + + private function relationshipField(self $factory): ?string + { + $factoryClass = $this->class; + $relationClass = $factory->class; + + // Check inversedBy side ($this is the owner of the relation) + $factoryClassMetadata = self::configuration()->objectManagerFor($factoryClass)->getMetadataFactory()->getMetadataFor($factoryClass); + foreach ($factoryClassMetadata->getAssociationNames() as $field) { + if (!$factoryClassMetadata->isAssociationInverseSide($field) && $factoryClassMetadata->getAssociationTargetClass($field) === $relationClass) { + return $field; + } + } + + // Check mappedBy side ($factory is the owner of the relation) + $relationClassMetadata = self::configuration()->objectManagerFor($relationClass)->getClassMetadata($relationClass); + foreach ($relationClassMetadata->getAssociationNames() as $field) { + if (($relationClassMetadata->isSingleValuedAssociation($field) || $relationClassMetadata->isCollectionValuedAssociation($field)) && $relationClassMetadata->getAssociationTargetClass($field) === $factoryClass) { + return $field; + } + } + + return null; // no relationship found } private function inverseRelationshipField(self $factory): ?string @@ -343,6 +387,35 @@ private function inverseRelationshipField(self $factory): ?string return null; // no relationship found } + private function hasCascadePersist(self $factory, ?string $field): bool + { + if (null === $field) { + return false; + } + + $factoryClass = $this->class; + $relationClass = $factory->class; + $classMetadataFactory = self::configuration()->objectManagerFor($factoryClass)->getMetadataFactory()->getMetadataFor($factoryClass); + $relationClassMetadata = self::configuration()->objectManagerFor($relationClass)->getClassMetadata($relationClass); + + if (!$relationClassMetadata instanceof ClassMetadataInfo || !$classMetadataFactory instanceof ClassMetadataInfo) { + return false; + } + + if ($relationClassMetadata->hasAssociation($field)) { + $inversedBy = $relationClassMetadata->getAssociationMapping($field)['inversedBy']; + if (null === $inversedBy) { + return false; + } + + $cascadeMetadata = $classMetadataFactory->getAssociationMapping($inversedBy)['cascade'] ?? []; + } else { + $cascadeMetadata = $classMetadataFactory->getAssociationMapping($field)['cascade'] ?? []; + } + + return \in_array('persist', $cascadeMetadata, true); + } + private function isPersisting(): bool { return self::configuration()->hasManagerRegistry() ? $this->persist : false; diff --git a/tests/Fixtures/Entity/Cascade/Brand.php b/tests/Fixtures/Entity/Cascade/Brand.php new file mode 100644 index 000000000..d9cc6fc24 --- /dev/null +++ b/tests/Fixtures/Entity/Cascade/Brand.php @@ -0,0 +1,56 @@ +products = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getProducts(): Collection + { + return $this->products; + } +} diff --git a/tests/Fixtures/Entity/Cascade/Category.php b/tests/Fixtures/Entity/Cascade/Category.php new file mode 100644 index 000000000..3d5983714 --- /dev/null +++ b/tests/Fixtures/Entity/Cascade/Category.php @@ -0,0 +1,56 @@ +products = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getProducts(): Collection + { + return $this->products; + } +} diff --git a/tests/Fixtures/Entity/Cascade/Image.php b/tests/Fixtures/Entity/Cascade/Image.php new file mode 100644 index 000000000..7319c474a --- /dev/null +++ b/tests/Fixtures/Entity/Cascade/Image.php @@ -0,0 +1,39 @@ +id; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function setPath(?string $path): void + { + $this->path = $path; + } +} diff --git a/tests/Fixtures/Entity/Cascade/Product.php b/tests/Fixtures/Entity/Cascade/Product.php new file mode 100644 index 000000000..7c4dd52dc --- /dev/null +++ b/tests/Fixtures/Entity/Cascade/Product.php @@ -0,0 +1,151 @@ +variants = new ArrayCollection(); + $this->categories = new ArrayCollection(); + $this->tags = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getBrand(): Brand + { + return $this->brand; + } + + public function setBrand(Brand $brand): void + { + $this->brand = $brand; + } + + public function getReview(): Review + { + return $this->review; + } + + public function setReview(Review $review): void + { + $this->review = $review; + } + + public function getVariants(): Collection + { + return $this->variants; + } + + public function addVariant(Variant $variant): void + { + if (!$this->variants->contains($variant)) { + $this->variants[] = $variant; + $variant->setProduct($this); + } + } + + public function removeVariant(Variant $variant): void + { + if ($this->variants->contains($variant)) { + $this->variants->removeElement($variant); + } + } + + public function getCategories(): Collection + { + return $this->categories; + } + + public function addCategory(Category $category): void + { + if (!$this->categories->contains($category)) { + $this->categories[] = $category; + } + } + + public function removeCategory(Category $category): void + { + if ($this->categories->contains($category)) { + $this->categories->removeElement($category); + } + } + + public function getTags(): Collection + { + return $this->tags; + } + + public function addTag(Tag $tag): void + { + if (!$this->tags->contains($tag)) { + $this->tags[] = $tag; + } + } + + public function removeTag(Tag $tag): void + { + if ($this->tags->contains($tag)) { + $this->tags->removeElement($tag); + } + } +} diff --git a/tests/Fixtures/Entity/Cascade/Review.php b/tests/Fixtures/Entity/Cascade/Review.php new file mode 100644 index 000000000..a22762147 --- /dev/null +++ b/tests/Fixtures/Entity/Cascade/Review.php @@ -0,0 +1,54 @@ +id; + } + + public function getProduct(): ?Product + { + return $this->product; + } + + public function setProduct(Product $product): void + { + $this->product = $product; + } + + public function getRank(): ?int + { + return $this->rank; + } + + public function setRank(?int $rank): void + { + $this->rank = $rank; + } +} diff --git a/tests/Fixtures/Entity/Cascade/Tag.php b/tests/Fixtures/Entity/Cascade/Tag.php new file mode 100644 index 000000000..fbd57d980 --- /dev/null +++ b/tests/Fixtures/Entity/Cascade/Tag.php @@ -0,0 +1,56 @@ +products = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getProducts(): Collection + { + return $this->products; + } +} diff --git a/tests/Fixtures/Entity/Cascade/Variant.php b/tests/Fixtures/Entity/Cascade/Variant.php new file mode 100644 index 000000000..668e9ea7d --- /dev/null +++ b/tests/Fixtures/Entity/Cascade/Variant.php @@ -0,0 +1,69 @@ +id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getProduct(): ?Product + { + return $this->product; + } + + public function setProduct(Product $product): void + { + $this->product = $product; + } + + public function getImage(): ?Image + { + return $this->image; + } + + public function setImage(Image $image): void + { + $this->image = $image; + } +} diff --git a/tests/Fixtures/Migrations/Version20210820131815.php b/tests/Fixtures/Migrations/Version20210820131815.php new file mode 100644 index 000000000..1e3115d26 --- /dev/null +++ b/tests/Fixtures/Migrations/Version20210820131815.php @@ -0,0 +1,41 @@ +addSql('CREATE TABLE brand_cascade (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE category_cascade (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE category_product (category_id INT NOT NULL, product_id INT NOT NULL, INDEX IDX_149244D312469DE2 (category_id), INDEX IDX_149244D34584665A (product_id), PRIMARY KEY(category_id, product_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE image_cascade (id INT AUTO_INCREMENT NOT NULL, path VARCHAR(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE product_cascade (id INT AUTO_INCREMENT NOT NULL, brand_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, INDEX IDX_D7FE16D844F5D008 (brand_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE product_tag (product_id INT NOT NULL, tag_id INT NOT NULL, INDEX IDX_E3A6E39C4584665A (product_id), INDEX IDX_E3A6E39CBAD26311 (tag_id), PRIMARY KEY(product_id, tag_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE review_cascade (id INT AUTO_INCREMENT NOT NULL, product_id INT DEFAULT NULL, `rank` INT NOT NULL, UNIQUE INDEX UNIQ_9DC9B99F4584665A (product_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE tag_cascade (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE variant_cascade (id INT AUTO_INCREMENT NOT NULL, product_id INT DEFAULT NULL, image_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, INDEX IDX_6982202E4584665A (product_id), UNIQUE INDEX UNIQ_6982202E3DA5256D (image_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE category_product ADD CONSTRAINT FK_149244D312469DE2 FOREIGN KEY (category_id) REFERENCES category_cascade (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE category_product ADD CONSTRAINT FK_149244D34584665A FOREIGN KEY (product_id) REFERENCES product_cascade (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE product_cascade ADD CONSTRAINT FK_D7FE16D844F5D008 FOREIGN KEY (brand_id) REFERENCES brand_cascade (id)'); + $this->addSql('ALTER TABLE product_tag ADD CONSTRAINT FK_E3A6E39C4584665A FOREIGN KEY (product_id) REFERENCES product_cascade (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE product_tag ADD CONSTRAINT FK_E3A6E39CBAD26311 FOREIGN KEY (tag_id) REFERENCES tag_cascade (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE review_cascade ADD CONSTRAINT FK_9DC9B99F4584665A FOREIGN KEY (product_id) REFERENCES product_cascade (id)'); + $this->addSql('ALTER TABLE variant_cascade ADD CONSTRAINT FK_6982202E4584665A FOREIGN KEY (product_id) REFERENCES product_cascade (id)'); + $this->addSql('ALTER TABLE variant_cascade ADD CONSTRAINT FK_6982202E3DA5256D FOREIGN KEY (image_id) REFERENCES image_cascade (id)'); + } + + public function down(Schema $schema): void + { + } +} diff --git a/tests/Functional/FactoryDoctrineCascadeTest.php b/tests/Functional/FactoryDoctrineCascadeTest.php new file mode 100644 index 000000000..de43a65c5 --- /dev/null +++ b/tests/Functional/FactoryDoctrineCascadeTest.php @@ -0,0 +1,135 @@ + + */ +final class FactoryDoctrineCascadeTest extends KernelTestCase +{ + use Factories, ResetDatabase; + + /** + * @test + */ + public function many_to_one_relationship(): void + { + $product = factory(Product::class, [ + 'name' => 'foo', + 'brand' => factory(Brand::class, ['name' => 'bar']), + ])->instantiateWith(function(array $attibutes, string $class): object { + $this->assertNull($attibutes['brand']->getId()); + + return (new Instantiator())($attibutes, $class); + })->create(); + + $this->assertNotNull($product->getBrand()->getId()); + $this->assertSame('bar', $product->getBrand()->getName()); + } + + /** + * @test + */ + public function one_to_many_relationship(): void + { + $product = factory(Product::class, [ + 'name' => 'foo', + 'variants' => [factory(Variant::class, ['name' => 'bar'])], + ])->instantiateWith(function(array $attibutes, string $class): object { + $this->assertNull($attibutes['variants'][0]->getId()); + + return (new Instantiator())($attibutes, $class); + })->create(); + + $this->assertCount(1, $product->getVariants()); + $this->assertNotNull($product->getVariants()->first()->getId()); + $this->assertSame('bar', $product->getVariants()->first()->getName()); + } + + /** + * @test + */ + public function many_to_many_relationship(): void + { + $product = factory(Product::class, [ + 'name' => 'foo', + 'tags' => [factory(Tag::class, ['name' => 'bar'])], + ])->instantiateWith(function(array $attibutes, string $class): object { + $this->assertNull($attibutes['tags'][0]->getId()); + + return (new Instantiator())($attibutes, $class); + })->create(); + + $this->assertCount(1, $product->getTags()); + $this->assertNotNull($product->getTags()->first()->getId()); + $this->assertSame('bar', $product->getTags()->first()->getName()); + } + + /** + * @test + */ + public function many_to_many_reverse_relationship(): void + { + $product = factory(Product::class, [ + 'name' => 'foo', + 'categories' => [factory(Category::class, ['name' => 'bar'])], + ])->instantiateWith(function(array $attibutes, string $class): object { + $this->assertNull($attibutes['categories'][0]->getId()); + + return (new Instantiator())($attibutes, $class); + })->create(); + + $this->assertCount(1, $product->getCategories()); + $this->assertNotNull($product->getCategories()->first()->getId()); + $this->assertSame('bar', $product->getCategories()->first()->getName()); + } + + /** + * @test + */ + public function one_to_one_relationship(): void + { + $variant = factory(Variant::class, [ + 'name' => 'foo', + 'image' => factory(Image::class, ['path' => '/path/to/file.extension']), + ])->instantiateWith(function(array $attibutes, string $class): object { + $this->assertNull($attibutes['image']->getId()); + + return (new Instantiator())($attibutes, $class); + })->create(); + + $this->assertNotNull($variant->getImage()->getId()); + $this->assertSame('/path/to/file.extension', $variant->getImage()->getPath()); + } + + /** + * @test + */ + public function one_to_one_reverse_relationship(): void + { + $product = factory(Product::class, [ + 'name' => 'foo', + 'review' => factory(Review::class, ['rank' => 4]), + ])->instantiateWith(function(array $attibutes, string $class): object { + $this->assertNull($attibutes['review']->getId()); + + return (new Instantiator())($attibutes, $class); + })->create(); + + $this->assertNotNull($product->getReview()->getId()); + $this->assertSame(4, $product->getReview()->getRank()); + } +} From 8769d7a5707715a9b1cf1df7019a3a84365d6d8e Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Mon, 23 Aug 2021 07:55:22 -0400 Subject: [PATCH 08/12] [minor] update factory maker template annotations --- src/Bundle/Resources/skeleton/Factory.tpl.php | 8 +++-- .../Bundle/Maker/MakeFactoryTest.php | 32 ++++++++++++------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/Bundle/Resources/skeleton/Factory.tpl.php b/src/Bundle/Resources/skeleton/Factory.tpl.php index 711e63cc2..80dda0ef7 100644 --- a/src/Bundle/Resources/skeleton/Factory.tpl.php +++ b/src/Bundle/Resources/skeleton/Factory.tpl.php @@ -10,9 +10,11 @@ use Zenstruck\Foundry\Proxy; /** + * @extends ModelFactory<getShortName() ?>> + * * @method static getShortName() ?>|Proxy createOne(array $attributes = []) - * @method static getShortName() ?>[]|Proxy[] createMany(int $number, $attributes = []) - * @method static getShortName() ?>|Proxy find($criteria) + * @method static getShortName() ?>[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static getShortName() ?>|Proxy find(object|array|mixed $criteria) * @method static getShortName() ?>|Proxy findOrCreate(array $attributes) * @method static getShortName() ?>|Proxy first(string $sortedField = 'id') * @method static getShortName() ?>|Proxy last(string $sortedField = 'id') @@ -24,7 +26,7 @@ * @method static getShortName() ?>[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) * @method static getShortName() ?>|RepositoryProxy repository() - * @method getShortName() ?>|Proxy create($attributes = []) + * @method getShortName() ?>|Proxy create(array|callable $attributes = []) */ final class extends ModelFactory { diff --git a/tests/Functional/Bundle/Maker/MakeFactoryTest.php b/tests/Functional/Bundle/Maker/MakeFactoryTest.php index 1eb86d646..1bad222ee 100644 --- a/tests/Functional/Bundle/Maker/MakeFactoryTest.php +++ b/tests/Functional/Bundle/Maker/MakeFactoryTest.php @@ -35,9 +35,11 @@ public function can_create_factory(): void use Zenstruck\\Foundry\\Proxy; /** + * @extends ModelFactory + * * @method static Category|Proxy createOne(array \$attributes = []) - * @method static Category[]|Proxy[] createMany(int \$number, \$attributes = []) - * @method static Category|Proxy find(\$criteria) + * @method static Category[]|Proxy[] createMany(int \$number, array|callable \$attributes = []) + * @method static Category|Proxy find(object|array|mixed \$criteria) * @method static Category|Proxy findOrCreate(array \$attributes) * @method static Category|Proxy first(string \$sortedField = 'id') * @method static Category|Proxy last(string \$sortedField = 'id') @@ -47,7 +49,7 @@ public function can_create_factory(): void * @method static Category[]|Proxy[] findBy(array \$attributes) * @method static Category[]|Proxy[] randomSet(int \$number, array \$attributes = []) * @method static Category[]|Proxy[] randomRange(int \$min, int \$max, array \$attributes = []) - * @method Category|Proxy create(\$attributes = []) + * @method Category|Proxy create(array|callable \$attributes = []) */ final class CategoryFactory extends ModelFactory { @@ -111,9 +113,11 @@ public function can_create_factory_interactively(): void use Zenstruck\\Foundry\\Proxy; /** + * @extends ModelFactory + * * @method static Tag|Proxy createOne(array \$attributes = []) - * @method static Tag[]|Proxy[] createMany(int \$number, \$attributes = []) - * @method static Tag|Proxy find(\$criteria) + * @method static Tag[]|Proxy[] createMany(int \$number, array|callable \$attributes = []) + * @method static Tag|Proxy find(object|array|mixed \$criteria) * @method static Tag|Proxy findOrCreate(array \$attributes) * @method static Tag|Proxy first(string \$sortedField = 'id') * @method static Tag|Proxy last(string \$sortedField = 'id') @@ -123,7 +127,7 @@ public function can_create_factory_interactively(): void * @method static Tag[]|Proxy[] findBy(array \$attributes) * @method static Tag[]|Proxy[] randomSet(int \$number, array \$attributes = []) * @method static Tag[]|Proxy[] randomRange(int \$min, int \$max, array \$attributes = []) - * @method Tag|Proxy create(\$attributes = []) + * @method Tag|Proxy create(array|callable \$attributes = []) */ final class TagFactory extends ModelFactory { @@ -183,9 +187,11 @@ public function can_create_factory_in_test_dir(): void use Zenstruck\\Foundry\\Proxy; /** + * @extends ModelFactory + * * @method static Category|Proxy createOne(array \$attributes = []) - * @method static Category[]|Proxy[] createMany(int \$number, \$attributes = []) - * @method static Category|Proxy find(\$criteria) + * @method static Category[]|Proxy[] createMany(int \$number, array|callable \$attributes = []) + * @method static Category|Proxy find(object|array|mixed \$criteria) * @method static Category|Proxy findOrCreate(array \$attributes) * @method static Category|Proxy first(string \$sortedField = 'id') * @method static Category|Proxy last(string \$sortedField = 'id') @@ -195,7 +201,7 @@ public function can_create_factory_in_test_dir(): void * @method static Category[]|Proxy[] findBy(array \$attributes) * @method static Category[]|Proxy[] randomSet(int \$number, array \$attributes = []) * @method static Category[]|Proxy[] randomRange(int \$min, int \$max, array \$attributes = []) - * @method Category|Proxy create(\$attributes = []) + * @method Category|Proxy create(array|callable \$attributes = []) */ final class CategoryFactory extends ModelFactory { @@ -259,9 +265,11 @@ public function can_create_factory_in_test_dir_interactively(): void use Zenstruck\\Foundry\\Proxy; /** + * @extends ModelFactory + * * @method static Tag|Proxy createOne(array \$attributes = []) - * @method static Tag[]|Proxy[] createMany(int \$number, \$attributes = []) - * @method static Tag|Proxy find(\$criteria) + * @method static Tag[]|Proxy[] createMany(int \$number, array|callable \$attributes = []) + * @method static Tag|Proxy find(object|array|mixed \$criteria) * @method static Tag|Proxy findOrCreate(array \$attributes) * @method static Tag|Proxy first(string \$sortedField = 'id') * @method static Tag|Proxy last(string \$sortedField = 'id') @@ -271,7 +279,7 @@ public function can_create_factory_in_test_dir_interactively(): void * @method static Tag[]|Proxy[] findBy(array \$attributes) * @method static Tag[]|Proxy[] randomSet(int \$number, array \$attributes = []) * @method static Tag[]|Proxy[] randomRange(int \$min, int \$max, array \$attributes = []) - * @method Tag|Proxy create(\$attributes = []) + * @method Tag|Proxy create(array|callable \$attributes = []) */ final class TagFactory extends ModelFactory { From 60f78b36b78152cbc9b42ab22bbefc6ae8797ebf Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Mon, 23 Aug 2021 07:55:50 -0400 Subject: [PATCH 09/12] [doc] add note about simplified factory annotations in PhpStorm 2021.2+ --- README.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2e9806777..f2a6fcc34 100644 --- a/README.md +++ b/README.md @@ -199,8 +199,8 @@ use Zenstruck\Foundry\Proxy; * @extends ModelFactory * * @method static Post|Proxy createOne(array $attributes = []) - * @method static Post[]|Proxy[] createMany(int $number, $attributes = []) - * @method static Post|Proxy find($criteria) + * @method static Post[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static Post|Proxy find(object|array|mixed $criteria) * @method static Post|Proxy findOrCreate(array $attributes) * @method static Post|Proxy first(string $sortedField = 'id') * @method static Post|Proxy last(string $sortedField = 'id') @@ -244,7 +244,25 @@ final class PostFactory extends ModelFactory } ``` -**TIP**: Using `make:factory --test` will generate the factory in `tests/Factory`! +**TIPS**: +1. Using `make:factory --test` will generate the factory in `tests/Factory`. +2. PhpStorm 2021.2+ has support for + [_generics_ annotations](https://blog.jetbrains.com/phpstorm/2021/07/phpstorm-2021-2-release/#generics), + with it, your factory's annotations can be reduced to the following and still have the same + auto-completion support: + ```php + /** + * @extends ModelFactory + * + * @method static Post[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static PostRepository|RepositoryProxy repository() + * @method Post|Proxy create(array|callable $attributes = []) + */ + final class PostFactory extends ModelFactory + { + // ... + } + ``` In the `getDefaults()`, you can return an array of all default values that any new object should have. [Faker](#faker) is available to easily get random data: From d4943d78eba284b82377ceb083489fc9979cbe8c Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Mon, 23 Aug 2021 08:23:32 -0400 Subject: [PATCH 10/12] [minor] exclude maker templates from phpunit code coverage --- phpunit-dama-doctrine.xml.dist | 3 +++ phpunit.xml.dist | 3 +++ 2 files changed, 6 insertions(+) diff --git a/phpunit-dama-doctrine.xml.dist b/phpunit-dama-doctrine.xml.dist index 73923e9cc..6999b9364 100644 --- a/phpunit-dama-doctrine.xml.dist +++ b/phpunit-dama-doctrine.xml.dist @@ -25,6 +25,9 @@ ./src/ + + ./src/Bundle/Resources/ + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 29d74e997..63f6c1368 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -24,6 +24,9 @@ ./src/ + + ./src/Bundle/Resources/ + From 31b7569861d77ec54605c14d423934ef399f4a9e Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Sun, 29 Aug 2021 06:53:44 -0400 Subject: [PATCH 11/12] [doc] switch documentation to symfony.com bundle doc format (#190) Co-authored-by: Wouter J --- .gitattributes | 2 + .github/workflows/ci.yml | 21 + .gitignore | 2 + .symfony.bundle.yaml | 6 + README.md | 1697 +--------------- bin/build-docs | 4 + run-tests => bin/run-tests | 0 bin/tools/docs/composer.json | 11 + bin/tools/docs/composer.lock | 1733 ++++++++++++++++ composer.json | 4 + docs/index.rst | 1739 +++++++++++++++++ .../DependencyInjection/Configuration.php | 2 +- src/Bundle/Maker/MakeFactory.php | 2 +- src/Bundle/Maker/MakeStory.php | 2 +- src/Bundle/Resources/skeleton/Factory.tpl.php | 6 +- src/Bundle/Resources/skeleton/Story.tpl.php | 2 +- src/Instantiator.php | 4 +- src/Proxy.php | 2 +- .../Bundle/Maker/MakeFactoryTest.php | 24 +- .../Functional/Bundle/Maker/MakeStoryTest.php | 8 +- tests/Unit/InstantiatorTest.php | 12 +- 21 files changed, 3555 insertions(+), 1728 deletions(-) create mode 100644 .symfony.bundle.yaml create mode 100755 bin/build-docs rename run-tests => bin/run-tests (100%) create mode 100644 bin/tools/docs/composer.json create mode 100644 bin/tools/docs/composer.lock create mode 100644 docs/index.rst diff --git a/.gitattributes b/.gitattributes index d14318e1e..a9163c7d6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,11 +2,13 @@ /.github export-ignore /tests export-ignore +/bin export-ignore /.codecov.yml export-ignore /.gitattributes export-ignore /.gitignore export-ignore /.php-cs-fixer.dist.php export-ignore /.scrutinizer.yml export-ignore +/.symfony.bundle.yaml export-ignore /phpunit.xml.dist export-ignore /phpunit-dama-doctrine.xml.dist export-ignore /psalm.xml export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8d1b9032..16361299f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -278,6 +278,27 @@ jobs: - name: Validate composer.json run: composer validate --strict --no-check-lock + build-docs: + name: Build Documentation + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2.3.3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 7.4 + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v1 + with: + composer-options: --prefer-dist + + - name: Build docs + run: bin/build-docs + cs-check: name: PHP Coding Standards runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index baeb6f206..6f6dd37ff 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ /phpunit.xml /phpunit-dama-doctrine.xml /vendor/ +/bin/tools/*/vendor/ /build/ /.php-cs-fixer.cache /.phpunit.result.cache /tests/Fixtures/tmp /var/ +/docs/output/ diff --git a/.symfony.bundle.yaml b/.symfony.bundle.yaml new file mode 100644 index 000000000..10f9fcfec --- /dev/null +++ b/.symfony.bundle.yaml @@ -0,0 +1,6 @@ +branches: ['master'] +maintained_branches: ['master'] +current_branch: "master" +dev_branch: "master" +doc_dir: 'docs/' +dev_branch_alias: '1.x' diff --git a/README.md b/README.md index f2a6fcc34..e2dc4ace9 100644 --- a/README.md +++ b/README.md @@ -22,1702 +22,7 @@ to load fixtures or inside your tests, [where it has even more features](#using- Want to watch a screencast 🎥 about it? Check out https://symfonycasts.com/foundry -## Documentation - -1. [Installation](#installation) -2. [Same Entities used in these Docs](#same-entities-used-in-these-docs) -3. [Model Factories](#model-factories) - 1. [Generate](#generate) - 2. [Using your Factory](#using-your-factory) - 3. [Reusable Model Factory "States"](#reusable-model-factory-states) - 4. [Attributes](#attributes) - 5. [Faker](#faker) - 6. [Events / Hooks](#events--hooks) - 7. [Initialization](#initialization) - 8. [Instantiation](#instantiation) - 9. [Immutable](#immutable) - 10. [Doctrine Relationships](#doctrine-relationships) - 11. [Factories as Services](#factories-as-services) - 12. [Anonymous Factories](#anonymous-factories) - 13. [Without Persisting](#without-persisting) -4. [Using with DoctrineFixturesBundle](#using-with-doctrinefixturesbundle) -5. [Using in your Tests](#using-in-your-tests) - 1. [Enable Foundry in your TestCase](#enable-foundry-in-your-testcase) - 2. [Database Reset](#database-reset) - 3. [Object Proxy](#object-proxy) - 1. [Force Setting](#force-setting) - 2. [Auto-Refresh](#auto-refresh) - 4. [Repository Proxy](#repository-proxy) - 5. [Assertions](#assertions) - 6. [Global State](#global-state) - 7. [PHPUnit Data Providers](#phpunit-data-providers) - 8. [Performance](#performance) - 1. [DAMADoctrineTestBundle](#damadoctrinetestbundle) - 2. [Miscellaneous](#miscellaneous) - 9. [Non-Kernel Test](#non-kernel-tests) - 10. [Test-Only Configuration](#test-only-configuration) - 11. [Using without the Bundle](#using-without-the-bundle) -6. [Stories](#stories) - 1. [Stories as Services](#stories-as-services) - 2. [Story State](#story-state) -7. [Bundle Configuration](#bundle-configuration) -8. [Credit](#credit) - -## Installation - - $ composer require zenstruck/foundry --dev - -To use the `make:*` commands from this bundle, ensure -[Symfony MakerBundle](https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html) -is installed. - -*If not using Symfony Flex, be sure to enable the bundle in your **test**/**dev** environments.* - -## Same Entities used in these Docs - -For the remainder of the documentation, the following sample entities will be used: - -```php -namespace App\Entity; - -use Doctrine\ORM\Mapping as ORM; - -/** - * @ORM\Entity(repositoryClass="App\Repository\CategoryRepository") - */ -class Category -{ - /** - * @ORM\Id() - * @ORM\GeneratedValue() - * @ORM\Column(type="integer") - */ - private $id; - - /** - * @ORM\Column(type="string", length=255) - */ - private $name; - - public function __construct(string $name) - { - $this->name = $name; - } - - // ... getters/setters -} -``` - -```php -namespace App\Entity; - -use Doctrine\ORM\Mapping as ORM; - -/** - * @ORM\Entity(repositoryClass="App\Repository\PostRepository") - */ -class Post -{ - /** - * @ORM\Id() - * @ORM\GeneratedValue() - * @ORM\Column(type="integer") - */ - private $id; - - /** - * @ORM\Column(type="string", length=255) - */ - private $title; - - /** - * @ORM\Column(type="text", nullable=true) - */ - private $body; - - /** - * @ORM\Column(type="datetime") - */ - private $createdAt; - - /** - * @ORM\Column(type="datetime", nullable=true) - */ - private $publishedAt; - - /** - * @ORM\ManyToOne(targetEntity=Category::class) - * @ORM\JoinColumn - */ - private $category; - - public function __construct(string $title) - { - $this->title = $title; - $this->createdAt = new \DateTime('now'); - } - - // ... getters/setters -} -``` - -## Model Factories - -The nicest way to use Foundry is to generate one *factory* class per entity. You can skip this -and use [anonymous factories](#anonymous-factories), but *model factories* give you IDE auto-completion -and access to other useful features. - -### Generate - -Create a model factory for one of your entities with the maker command: - -``` -$ bin/console make:factory - -> Entity class to create a factory for: -> Post - -created: src/Factory/PostFactory.php - -Next: Open your new factory and set default values/states. -``` - -This command will generate a `PostFactory` class that looks like this: - -```php -// src/Factory/PostFactory.php - -namespace App\Factory; - -use App\Entity\Post; -use App\Repository\PostRepository; -use Zenstruck\Foundry\RepositoryProxy; -use Zenstruck\Foundry\ModelFactory; -use Zenstruck\Foundry\Proxy; - -/** - * @extends ModelFactory - * - * @method static Post|Proxy createOne(array $attributes = []) - * @method static Post[]|Proxy[] createMany(int $number, array|callable $attributes = []) - * @method static Post|Proxy find(object|array|mixed $criteria) - * @method static Post|Proxy findOrCreate(array $attributes) - * @method static Post|Proxy first(string $sortedField = 'id') - * @method static Post|Proxy last(string $sortedField = 'id') - * @method static Post|Proxy random(array $attributes = []) - * @method static Post|Proxy randomOrCreate(array $attributes = [])) - * @method static Post[]|Proxy[] all() - * @method static Post[]|Proxy[] findBy(array $attributes) - * @method static Post[]|Proxy[] randomSet(int $number, array $attributes = [])) - * @method static Post[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])) - * @method static PostRepository|RepositoryProxy repository() - * @method Post|Proxy create(array|callable $attributes = []) - */ -final class PostFactory extends ModelFactory -{ - public function __construct() - { - parent::__construct(); - - // TODO inject services if required (https://github.com/zenstruck/foundry#factories-as-services) - } - - protected function getDefaults(): array - { - return [ - // TODO add your default values here (https://github.com/zenstruck/foundry#model-factories) - ]; - } - - protected function initialize(): self - { - // see https://github.com/zenstruck/foundry#initialization - return $this - // ->afterInstantiate(function(Post $post) {}) - ; - } - - protected static function getClass(): string - { - return Post::class; - } -} -``` - -**TIPS**: -1. Using `make:factory --test` will generate the factory in `tests/Factory`. -2. PhpStorm 2021.2+ has support for - [_generics_ annotations](https://blog.jetbrains.com/phpstorm/2021/07/phpstorm-2021-2-release/#generics), - with it, your factory's annotations can be reduced to the following and still have the same - auto-completion support: - ```php - /** - * @extends ModelFactory - * - * @method static Post[]|Proxy[] createMany(int $number, array|callable $attributes = []) - * @method static PostRepository|RepositoryProxy repository() - * @method Post|Proxy create(array|callable $attributes = []) - */ - final class PostFactory extends ModelFactory - { - // ... - } - ``` - -In the `getDefaults()`, you can return an array of all default values that any new object -should have. [Faker](#faker) is available to easily get random data: - -```php -protected function getDefaults(): array -{ - return [ - // Symfony's property-access component is used to populate the properties - // this means that setTitle() will be called or you can have a $title constructor argument - 'title' => self::faker()->unique()->sentence(), - 'body' => self::faker()->sentence(), - ]; -} -``` - -**TIP**: It is best to have `getDefaults()` return the attributes to persist a valid object -(all non-nullable fields). - -### Using your Factory - -```php -use App\Factory\PostFactory; - -// create/persist Post with random data from `getDefaults()` -PostFactory::createOne(); - -// or provide values for some properties (others will be random) -PostFactory::createOne(['title' => 'My Title']); - -// createOne() returns the persisted Post object wrapped in a Proxy object -$post = PostFactory::createOne(); - -// the "Proxy" magically calls the underlying Post methods and is type-hinted to "Post" -$title = $post->getTitle(); // getTitle() can be autocompleted by your IDE! - -// if you need the actual Post object, use ->object() -$realPost = $post->object(); - -// create/persist 5 Posts with random data from getDefaults() -PostFactory::createMany(5); // returns Post[]|Proxy[] -PostFactory::createMany(5, ['title' => 'My Title']); - -// find a persisted object for the given attributes, if not found, create with the attributes -PostFactory::findOrCreate(['title' => 'My Title']); // returns Post|Proxy - -PostFactory::first(); // get the first object (assumes an auto-incremented "id" column) -PostFactory::first('createdAt'); // assuming "createdAt" is a datetime column, this will return latest object -PostFactory::last(); // get the last object (assumes an auto-incremented "id" column) -PostFactory::last('createdAt'); // assuming "createdAt" is a datetime column, this will return oldest object - -PostFactory::truncate(); // empty the database table - -PostFactory::count(); // the number of persisted Posts - -PostFactory::all(); // Post[]|Proxy[] all the persisted Posts - -PostFactory::findBy(['author' => 'kevin']); // Post[]|Proxy[] matching the filter - -$post = PostFactory::find(5); // Post|Proxy with the id of 5 -$post = PostFactory::find(['title' => 'My First Post']); // Post|Proxy matching the filter - -// get a random object that has been persisted -$post = PostFactory::random(); // returns Post|Proxy -$post = PostFactory::random(['author' => 'kevin']); // filter by the passed attributes - -// or automatically persist a new random object if none exists -$post = PostFactory::randomOrCreate(); -$post = PostFactory::randomOrCreate(['author' => 'kevin']); // filter by or create with the passed attributes - -// get a random set of objects that have been persisted -$posts = PostFactory::randomSet(4); // array containing 4 "Post|Proxy" objects -$posts = PostFactory::randomSet(4, ['author' => 'kevin']); // filter by the passed attributes - -// random range of persisted objects -$posts = PostFactory::randomRange(0, 5); // array containing 0-5 "Post|Proxy" objects -$posts = PostFactory::randomRange(0, 5, ['author' => 'kevin']); // filter by the passed attributes -``` - -### Reusable Model Factory "States" - -You can add any methods you want to your model factories (i.e. static methods that create an object in a certain way) but -you can also add *states*: - -```php -namespace App\Factory; - -use App\Entity\Post; -use Zenstruck\Foundry\ModelFactory; - -final class PostFactory extends ModelFactory -{ - // ... - - public function published(): self - { - // call setPublishedAt() and pass a random DateTime - return $this->addState(['published_at' => self::faker()->dateTime()]); - } - - public function unpublished(): self - { - return $this->addState(['published_at' => null]); - } - - public function withViewCount(int $count = null): self - { - return $this->addState(function () use ($count) { - return ['view_count' => $count ?? self::faker()->numberBetween(0, 10000)]; - }); - } -} -``` - -You can use states to make your tests very explicit to improve readability: - -```php -// never use the constructor (i.e. "new PostFactory()"), but use the -// "new()" method. After defining the states, call "create()" to create -// and persist the model. -$post = PostFactory::new()->unpublished()->create(); -$post = PostFactory::new()->withViewCount(3)->create(); - -// combine multiple states -$post = PostFactory::new() - ->unpublished() - ->withViewCount(10) - ->create() -; - -// states that don't require arguments can be added as strings to PostFactory::new() -$post = PostFactory::new('published', 'withViewCount')->create(); -``` - -### Attributes - -The attributes used to instantiate the object can be added several ways. Attributes can be an *array*, or a *callable* -that returns an array. Using a *callable* ensures random data as the callable is run for each object separately during -instantiation. - -```php -use App\Entity\Category; -use App\Entity\Post; -use App\Factory\CategoryFactory; -use App\Factory\PostFactory; -use function Zenstruck\Foundry\faker; - -// The first argument to "new()" allows you to overwrite the default -// values that are defined in the `PostFactory::getDefaults()` -$posts = PostFactory::new(['title' => 'Post A']) - ->withAttributes([ - 'body' => 'Post Body...', - - // CategoryFactory will be used to create a new Category for each Post - 'category' => CategoryFactory::new(['name' => 'php']), - ]) - ->withAttributes([ - // Proxies are automatically converted to their wrapped object - 'category' => CategoryFactory::createOne(), - ]) - ->withAttributes(function() { return ['createdAt' => faker()->dateTime()]; }) // see faker section below - - // create "2" Post's - ->many(2)->create(['title' => 'Different Title']) -; - -$posts[0]->getTitle(); // "Different Title" -$posts[0]->getBody(); // "Post Body..." -$posts[0]->getCategory(); // random Category -$posts[0]->getPublishedAt(); // \DateTime('last week') -$posts[0]->getCreatedAt(); // random \DateTime - -$posts[1]->getTitle(); // "Different Title" -$posts[1]->getBody(); // "Post Body..." -$posts[1]->getCategory(); // random Category (different than above) -$posts[1]->getPublishedAt(); // \DateTime('last week') -$posts[1]->getCreatedAt(); // random \DateTime (different than above) -``` - -### Faker - -This library provides a wrapper for [FakerPHP/faker](https://fakerphp.github.io/) to help with generating -random data for your factories: - -```php -use Zenstruck\Foundry\Factory; -use function Zenstruck\Foundry\faker; - -Factory::faker()->name(); // random name - -// alternatively, use the helper function -faker()->email(); // random email -``` - -**NOTE**: You can register your own `Faker\Generator`: - -```yaml -# config/packages/dev/zenstruck_foundry.yaml (see Bundle Configuration section about sharing this in the test environment) -zenstruck_foundry: - faker: - locale: fr_FR # set the locale - # or - service: my_faker # use your own instance of Faker\Generator for complete control -``` - -### Events / Hooks - -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 App\Factory\PostFactory; -use Zenstruck\Foundry\Proxy; - -PostFactory::new() - ->beforeInstantiate(function(array $attributes): array { - // $attributes is what will be used to instantiate the object, manipulate as required - $attributes['title'] = 'Different title'; - - return $attributes; // must return the final $attributes - }) - ->afterInstantiate(function(Post $object, array $attributes): void { - // $object is the instantiated object - // $attributes contains the attributes used to instantiate the object and any extras - }) - ->afterPersist(function(Proxy $object, array $attributes) { - /* @var Post $object */ - // this event is only called if the object was persisted - // $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 - }) - - // multiple events are allowed - ->beforeInstantiate(function($attributes) { return $attributes; }) - ->afterInstantiate(function() {}) - ->afterPersist(function() {}) -; -``` - -You can also add hooks directly in your model factory class: - -```php -protected function initialize(): self -{ - return $this - ->afterPersist(function() {}) - ; -} -``` - -Read [Initialization](#initialization) to learn more about the `initialize()` method. - -### Initialization - -You can override your model factory's `initialize()` method to add default state/logic: - -```php -namespace App\Factory; - -use App\Entity\Post; -use Zenstruck\Foundry\ModelFactory; - -final class PostFactory extends ModelFactory -{ - // ... - - protected function initialize(): self - { - return $this - ->published() // published by default - ->instantiateWith(function (array $attributes) { - return new Post(); // custom instantiation for this factory - }) - ->afterPersist(function () {}) // default event for this factory - ; - } -} -``` - -**NOTE**: Be sure to chain the states/hooks off of `$this` because factories are [Immutable](#immutable). - -### Instantiation - -By default, objects are instantiated in the normal fashion, by using the object's constructor. Attributes -that match constructor arguments are used. Remaining attributes are set to the object using Symfony's -[PropertyAccess](https://symfony.com/doc/current/components/property_access.html) component (setters/public -properties). Any extra attributes cause an exception to be thrown. - -You can customize the instantiator in several ways: - -```php -use App\Entity\Post; -use App\Factory\PostFactory; -use Zenstruck\Foundry\Instantiator; - -// set the instantiator for the current factory -PostFactory::new() - // instantiate the object without calling the constructor - ->instantiateWith((new Instantiator())->withoutConstructor()) - - // "foo" and "bar" attributes are ignored when instantiating - ->instantiateWith((new Instantiator())->allowExtraAttributes(['foo', 'bar'])) - - // all extra attributes are ignored when instantiating - ->instantiateWith((new Instantiator())->allowExtraAttributes()) - - // force set "title" and "body" when instantiating - ->instantiateWith((new Instantiator())->alwaysForceProperties(['title', 'body'])) - - // never use setters, always "force set" properties (even private/protected, does not use setter) - ->instantiateWith((new Instantiator())->alwaysForceProperties()) - - // can combine the different "modes" - ->instantiateWith((new Instantiator())->withoutConstructor()->allowExtraAttributes()->alwaysForceProperties()) - - // the instantiator is just a callable, you can provide your own - ->instantiateWith(function(array $attibutes, string $class): object { - return new Post(); // ... your own logic - }) -; -``` - -You can customize the instantiator globally for all your factories (can still be overruled by factory instance -instantiators): - -```yaml -# config/packages/dev/zenstruck_foundry.yaml (see Bundle Configuration section about sharing this in the test environment) -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 - -Factory's are immutable: - -```php -use App\Factory\PostFactory; - -$factory = PostFactory::new(); -$factory1 = $factory->withAttributes([]); // returns a new PostFactory object -$factory2 = $factory->instantiateWith(function () {}); // returns a new PostFactory object -$factory3 = $factory->beforeInstantiate(function () {}); // returns a new PostFactory object -$factory4 = $factory->afterInstantiate(function () {}); // returns a new PostFactory object -$factory5 = $factory->afterPersist(function () {}); // returns a new PostFactory object -``` - -### 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. There are some -nuances with the different relationships and how entities are created. The following tries to document these for -each relationship type. - -#### Many-to-One - -The following assumes the `Comment` entity has a many-to-one relationship with `Post`: - -```php -use App\Factory\CommentFactory; -use App\Factory\PostFactory; - -// Example 1: pre-create Post and attach to Comment -$post = PostFactory::createOne(); // instance of Proxy - -CommentFactory::createOne(['post' => $post]); -CommentFactory::createOne(['post' => $post->object()]); // functionally the same as above - -// Example 2: pre-create Posts and choose a random one -PostFactory::createMany(5); // create 5 Posts - -CommentFactory::createOne(['post' => PostFactory::random()]); - -// or create many, each with a different random Post -CommentFactory::createMany( - 5, // create 5 comments - function() { // note the callback - this ensures that each of the 5 comments has a different Post - return ['post' => PostFactory::random()]; // each comment set to a random Post from those already in the database - } -); - -// Example 3: create a separate Post for each Comment -CommentFactory::createMany(5, [ - // this attribute is an instance of PostFactory that is created separately for each Comment created - 'post' => PostFactory::new(), -]); - -// Example 4: create multiple Comments with the same Post -CommentFactory::createMany(5, [ - 'post' => PostFactory::createOne(), // note the "createOne()" here -]); -``` - -**TIP 1**: It is recommended that the only relationship you define in `ModelFactory::getDefaults()` is non-null -Many-to-One's. - -**TIP 2**: It is also recommended that your `ModelFactory::getDefaults()` return a `Factory` and not the created entity: - -```php -protected function getDefaults(): array -{ - return [ - // RECOMMENDED - 'post' => PostFactory::new(), - 'post' => PostFactory::new()->published(), - - // NOT RECOMMENDED - will potentially result in extra unintended Posts - 'post' => PostFactory::createOne(), - 'post' => PostFactory::new()->published()->create(), - ]; -} -``` - -#### One-to-Many - -The following assumes the `Post` entity has a one-to-many relationship with `Comment`: - -```php -use App\Factory\CommentFactory; -use App\Factory\PostFactory; - -// Example 1: Create a Post with 6 Comments -PostFactory::createOne(['comments' => CommentFactory::new()->many(6)]); - -// Example 2: Create 6 Posts each with 4 Comments (24 Comments total) -PostFactory::createMany(6, ['comments' => CommentFactory::new()->many(4)]); - -// Example 3: Create 6 Posts each with between 0 and 10 Comments -PostFactory::createMany(6, ['comments' => CommentFactory::new()->many(0, 10)]); -``` - -#### Many-to-Many - -The following assumes the `Post` entity has a many-to-many relationship with `Tag`: - -```php -use App\Factory\PostFactory; -use App\Factory\TagFactory; - -// Example 1: pre-create Tags and attach to Post -$tags = TagFactory::createMany(3); - -PostFactory::createOne(['tags' => $tags]); - -// Example 2: pre-create Tags and choose a random set -TagFactory::createMany(10); - -PostFactory::new() - ->many(5) // create 5 posts - ->create(function() { // note the callback - this ensures that each of the 5 posts has a different random set - return ['tags' => TagFactory::randomSet(2)]; // each post uses 2 random tags from those already in the database - }) -; - -// Example 3: pre-create Tags and choose a random range -TagFactory::createMany(10); - -PostFactory::new() - ->many(5) // create 5 posts - ->create(function() { // note the callback - this ensures that each of the 5 posts has a different random range - return ['tags' => TagFactory::randomRange(0, 5)]; // each post uses between 0 and 5 random tags from those already in the database - }) -; - -// Example 4: create 3 Posts each with 3 unique Tags -PostFactory::createMany(3, ['tags' => TagFactory::new()->many(3)]); - -// Example 5: create 3 Posts each with between 0 and 3 unique Tags -PostFactory::createMany(3, ['tags' => TagFactory::new()->many(0, 3)]); -``` - -### Factories as Services - -If your factories require dependencies, you can define them as a service. The following example demonstrates a very -common use-case: encoding a password with the `UserPasswordEncoderInterface` service. - -```php -// src/Factory/UserFactory.php - -namespace App\Factory; - -use App\Entity\User; -use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; -use Zenstruck\Foundry\ModelFactory; - -final class UserFactory extends ModelFactory -{ - private $passwordEncoder; - - public function __construct(UserPasswordEncoderInterface $passwordEncoder) - { - parent::__construct(); - - $this->passwordEncoder = $passwordEncoder; - } - - protected function getDefaults(): array - { - return [ - 'email' => self::faker()->unique()->safeEmail(), - 'password' => '1234', - ]; - } - - protected function initialize(): self - { - return $this - ->afterInstantiate(function(User $user) { - $user->setPassword($this->passwordEncoder->encodePassword($user, $user->getPassword())); - }) - ; - } - - protected static function getClass(): string - { - return User::class; - } -} -``` - -If using a standard Symfony Flex app, this will be autowired/autoconfigured. If not, register the service and tag -with `foundry.factory`. - -Use the factory as normal: - -```php -UserFactory::createOne(['password' => 'mypass'])->getPassword(); // "mypass" encoded -UserFactory::createOne()->getPassword(); // "1234" encoded (because "1234" is set as the default password) -``` - -**NOTES**: -1. The provided bundle is required for factories as services. -2. If using `make:factory --test`, factories will be created in the `tests/Factory` directory which is not -autowired/autoconfigured in a standard Symfony Flex app. You will have to manually register these as -services. - -### Anonymous Factories - -Foundry can be used to create factories for entities that you don't have model factories for: - -```php -use App\Entity\Post; -use Zenstruck\Foundry\AnonymousFactory; -use function Zenstruck\Foundry\factory; -use function Zenstruck\Foundry\create; -use function Zenstruck\Foundry\create_many; - -$factory = AnonymousFactory::new(Post::class); -$factory = factory(Post::class); // alternative to above - -// has the same API as ModelFactory's -$factory->create(['field' => 'value']); -$factory->many(5)->create(['field' => 'value']); -$factory->instantiateWith(function () {}); -$factory->beforeInstantiate(function () {}); -$factory->afterInstantiate(function () {}); -$factory->afterPersist(function () {}); - -// find a persisted object for the given attributes, if not found, create with the attributes -$factory->findOrCreate(['title' => 'My Title']); - -$factory->first(); // get the first object (assumes an auto-incremented "id" column) -$factory->first('createdAt'); // assuming "createdAt" is a datetime column, this will return latest object -$factory->last(); // get the last object (assumes an auto-incremented "id" column) -$factory->last('createdAt'); // assuming "createdAt" is a datetime column, this will return oldest object - -$factory->truncate(); // empty the database table -$factory->count(); // the number of persisted Post's -$factory->all(); // Post[]|Proxy[] all the persisted Post's - -$factory->findBy(['author' => 'kevin']); // Post[]|Proxy[] matching the filter - -$factory->find(5); // Post|Proxy with the id of 5 -$factory->find(['title' => 'My First Post']); // Post|Proxy matching the filter - -// get a random object that has been persisted -$factory->random(); // returns Post|Proxy -$factory->random(['author' => 'kevin']); // filter by the passed attributes - -// or automatically persist a new random object if none exists -$factory->randomOrCreate(); -$factory->randomOrCreate(['author' => 'kevin']); // filter by or create with the passed attributes - -// get a random set of objects that have been persisted -$factory->randomSet(4); // array containing 4 "Post|Proxy" objects -$factory->randomSet(4, ['author' => 'kevin']); // filter by the passed attributes - -// random range of persisted objects -$factory->randomRange(0, 5); // array containing 0-5 "Post|Proxy" objects -$factory->randomRange(0, 5, ['author' => 'kevin']); // filter by the passed attributes - -// repository proxy wrapping PostRepository (see Repository Proxy section below) -$factory->repository(); - -// convenience functions -$entity = create(Post::class, ['field' => 'value']); -$entities = create_many(Post::class, 5, ['field' => 'value']); -``` - -### Without Persisting - -Factories can also create objects without persisting them. This can be useful for unit tests where you just want to test -the behaviour of the actual object or for creating objects that are not entities. When created, they are still wrapped -in a `Proxy` to optionally save later. - -```php -use App\Factory\PostFactory; -use App\Entity\Post; -use Zenstruck\Foundry\AnonymousFactory; -use function Zenstruck\Foundry\instantiate; -use function Zenstruck\Foundry\instantiate_many; - -$post = PostFactory::new()->withoutPersisting()->create(); // returns Post|Proxy -$post->setTitle('something else'); // do something with object -$post->save(); // persist the Post (save() is a method on Proxy) - -$post = PostFactory::new()->withoutPersisting()->create()->object(); // actual Post object - -$posts = PostFactory::new()->withoutPersisting()->many(5)->create(); // returns Post[]|Proxy[] - -// anonymous factories: -$factory = new AnonymousFactory(Post::class); - -$entity = $factory->withoutPersisting()->create(['field' => 'value']); // returns Post|Proxy - -$entity = $factory->withoutPersisting()->create(['field' => 'value'])->object(); // actual Post object - -$entities = $factory->withoutPersisting()->many(5)->create(['field' => 'value']); // returns Post[]|Proxy[] - -// convenience functions -$entity = instantiate(Post::class, ['field' => 'value']); -$entities = instantiate_many(Post::class, 5, ['field' => 'value']); -``` - -If you'd like your model factory to not persist by default, override its `initialize()` method to add this behaviour: - -```php -protected function initialize(): self -{ - return $this - ->withoutPersisting() - ; -} -``` - -Now, after creating objects using this factory, you'd have to call `->save()` to actually persist them to the database. - -**TIP**: If you'd like to disable persisting by default for all your model factories: - -1. Create an abstract model factory that extends `Zenstruck\Foundry\ModelFactory`. -2. Override the `initialize()` method as shown above. -3. Have all your model factories extend from this. - -## Using with DoctrineFixturesBundle - -Foundry works out of the box with [DoctrineFixturesBundle](https://symfony.com/doc/master/bundles/DoctrineFixturesBundle/index.html). -You can simply use your factories and stories right within your fixture files: - -```php -// src/DataFixtures/AppFixtures.php -namespace App\DataFixtures; - -use App\Factory\CategoryFactory; -use App\Factory\CommentFactory; -use App\Factory\PostFactory; -use App\Factory\TagFactory; -use Doctrine\Bundle\FixturesBundle\Fixture; -use Doctrine\Persistence\ObjectManager; - -class AppFixtures extends Fixture -{ - public function load(ObjectManager $manager) - { - // create 10 Category's - CategoryFactory::createMany(10); - - // create 20 Tag's - TagFactory::createMany(20); - - // create 50 Post's - PostFactory::createMany(50, function() { - return [ - // each Post will have a random Category (chosen from those created above) - 'category' => CategoryFactory::random(), - - // each Post will have between 0 and 6 Tag's (chosen from those created above) - 'tags' => TagFactory::randomRange(0, 6), - - // each Post will have between 0 and 10 Comment's that are created new - 'comments' => CommentFactory::new()->many(0, 10), - ]; - }); - } -} -``` - -Run the [`doctrine:fixtures:load`](https://symfony.com/doc/master/bundles/DoctrineFixturesBundle/index.html#loading-fixtures) -as normal to seed your database. - -## Using in your Tests - -Traditionally, data fixtures are defined in one or more files outside of your tests. When writing tests using these -fixtures, your fixtures are a sort of a *black box*. There is no clear connection between the fixtures and what you -are testing. - -Foundry allows each individual test to fully follow the [AAA](https://www.thephilocoder.com/unit-testing-aaa-pattern/) -("Arrange", "Act", "Assert") testing pattern. You create your fixtures using "factories" at the beginning of each test. -You only create fixtures that are applicable for the test. Additionally, these fixtures are created with only the -attributes required for the test - attributes that are not applicable are filled with random data. The created fixture -objects are wrapped in a "proxy" that helps with pre and post assertions. - -Let's look at an example: - -```php -public function test_can_post_a_comment(): void -{ - // 1. "Arrange" - $post = PostFactory::new() // New Post factory - ->published() // Make the post in a "published" state - ->create([ // Instantiate Post object and persist - 'slug' => 'post-a' // This test only requires the slug field - all other fields are random data - ]) - ; - - // 1a. "Pre-Assertions" - $this->assertCount(0, $post->getComments()); - - // 2. "Act" - static::ensureKernelShutdown(); // creating factories boots the kernel; shutdown before creating the client - $client = static::createClient(); - $client->request('GET', '/posts/post-a'); // Note the slug from the arrange step - $client->submitForm('Add', [ - 'comment[name]' => 'John', - 'comment[body]' => 'My comment', - ]); - - // 3. "Assert" - self::assertResponseRedirects('/posts/post-a'); - - $this->assertCount(1, $post->refresh()->getComments()); // Refresh $post from the database and call ->getComments() - - CommentFactory::assert()->exists([ // Doctrine repository assertions - 'name' => 'John', - 'body' => 'My comment', - ]); -} -``` - -### Enable Foundry in your TestCase - -Add the `Factories` trait for tests using factories: - -```php -use App\Factory\PostFactory; -use Zenstruck\Foundry\Test\Factories; -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; - -class MyTest extends WebTestCase -{ - use Factories; - - public function test_1(): void - { - $post = PostFactory::createOne(); - - // ... - } -} -``` - -### Database Reset - -This library requires that your database be reset before each test. The packaged `ResetDatabase` trait handles -this for you. - -```php -use Zenstruck\Foundry\Test\Factories; -use Zenstruck\Foundry\Test\ResetDatabase; -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; - -class MyTest extends WebTestCase -{ - use ResetDatabase, Factories; - - // ... -} -``` - -Before the first test using the `ResetDatabase` trait, it drops (if exists) and creates the test database. -Then, by default, before each test, it resets the schema using `doctrine:schema:drop`/`doctrine:schema:create`. - -Alternatively, you can have it run your migrations instead by setting the env variable `FOUNDRY_RESET_MODE=migrate` -(in your `.env.test`). When using this *mode*, before each test, the database is dropped/created and your migrations -run (via `doctrine:migrations:migrate`). This mode can really make your test suite slow (especially if you have a lot -of migrations). It is highly recommended to use [DamaDoctrineTestBundle](#damadoctrinetestbundle) to improve the -speed. When this bundle is enabled, the database is dropped/created and migrated only once for the suite. - -**TIP**: Create a base TestCase for tests using factories to avoid adding the traits to every TestCase. - -**NOTE**: If your tests [are not persisting](#without-persisting) the objects they create, these test traits are not -required. - -By default, `ResetDatabase` resets the default configured connection's database and default configured object manager's -schema. To customize the connection's and object manager's to be reset (or reset multiple connections/managers), set the -following environment variables: - -``` -# .env.test - -FOUNDRY_RESET_CONNECTIONS=connection1,connection2 -FOUNDRY_RESET_OBJECT_MANAGERS=manager1,manager2 -``` - -### Object Proxy - -Objects created by a factory are wrapped in a special *Proxy* object. These objects allow your doctrine entities -to have [Active Record](https://en.wikipedia.org/wiki/Active_record_pattern) *like* behavior: - -```php -use App\Factory\PostFactory; - -$post = PostFactory::createOne()->create(['title' => 'My Title']); // instance of Zenstruck\Foundry\Proxy - -// get the wrapped object -$realPost = $post->object(); // instance of Post - -// call any Post method -$post->getTitle(); // "My Title" - -// set property and save to the database -$post->setTitle('New Title'); -$post->save(); - -// refresh from the database -$post->refresh(); - -// delete from the database -$post->remove(); - -$post->repository(); // repository proxy wrapping PostRepository (see Repository Proxy section below) -``` - -#### Force Setting - -Object proxies have helper methods to access non-public properties of the object they wrap: - -```php -// set private/protected properties -$post->forceSet('createdAt', new \DateTime()); - -// get private/protected properties -$post->forceGet('createdAt'); -``` - -#### Auto-Refresh - -Object proxies have the option to enable *auto refreshing* that removes the need to call `->refresh()` before calling -methods on the underlying object. When auto-refresh is enabled, most calls to proxy objects first refresh the wrapped -object from the database. - -```php -use App\Factory\PostFactory; - -$post = PostFactory::new(['title' => 'Original Title']) - ->create() - ->enableAutoRefresh() -; - -// ... logic that changes the $post title to "New Title" (like your functional test) - -$post->getTitle(); // "New Title" (equivalent to $post->refresh()->getTitle()) -``` - -Without auto-refreshing enabled, the above call to `$post->getTitle()` would return "Original Title". - -**NOTE**: A situation you need to be aware of when using auto-refresh is that all methods refresh the object first. If -changing the object's state via multiple methods (or multiple force-sets), an "unsaved changes" exception will be -thrown: - -```php -use App\Factory\PostFactory; - -$post = PostFactory::new(['title' => 'Original Title', 'body' => 'Original Body']) - ->create() - ->enableAutoRefresh() -; - -$post->setTitle('New Title'); -$post->setBody('New Body'); // exception thrown because of "unsaved changes" to $post from above -``` - -To overcome this, you need to first disable auto-refreshing, then re-enable after making/saving the changes: - -```php -use App\Entity\Post; -use App\Factory\PostFactory; - -$post = PostFactory::new(['title' => 'Original Title', 'body' => 'Original Body']) - ->create() - ->enableAutoRefresh() -; - -$post->disableAutoRefresh(); -$post->setTitle('New Title'); // or using ->forceSet('title', 'New Title') -$post->setBody('New Body'); // or using ->forceSet('body', 'New Body') -$post->enableAutoRefresh(); -$post->save(); - -$post->getBody(); // "New Body" -$post->getTitle(); // "New Title" - -// alternatively, use the ->withoutAutoRefresh() helper which first disables auto-refreshing, then re-enables after -// executing the callback. -$post->withoutAutoRefresh(function (Post $post) { // can pass either Post or Proxy to the callback - $post->setTitle('New Title'); - $post->setBody('New Body'); -}); -$post->save(); - -// if force-setting properties, you can use the ->forceSetAll() helper: -$post->forceSetAll([ - 'title' => 'New Title', - 'body' => 'New Body', -]); -$post->save(); -``` - -**NOTE**: You can enable/disable auto-refreshing globally to have every proxy auto-refreshable by default or not. When -enabled, you will have to *opt-out* of auto-refreshing. - -```yaml -# config/packages/dev/zenstruck_foundry.yaml (see Bundle Configuration section about sharing this in the test environment) -zenstruck_foundry: - auto_refresh_proxies: true/false -``` - -### Repository Proxy - -This library provides a *Repository Proxy* that wraps your object repositories to provide useful assertions and methods: - -```php -use App\Entity\Post; -use App\Factory\PostFactory; -use function Zenstruck\Foundry\repository; - -// instance of RepositoryProxy that wraps PostRepository -$repository = PostFactory::repository(); - -// alternative to above for proxying repository you haven't created model factories for -$repository = repository(Post::class); - -// helpful methods - all returned object(s) are proxied -$repository->count(); // number of rows in the database table -count($repository); // equivalent to above (RepositoryProxy implements \Countable) -$repository->first(); // get the first object (assumes an auto-incremented "id" column) -$repository->first('createdAt'); // assuming "createdAt" is a datetime column, this will return latest object -$repository->last(); // get the last object (assumes an auto-incremented "id" column) -$repository->last('createdAt'); // assuming "createdAt" is a datetime column, this will return oldest object -$repository->truncate(); // delete all rows in the database table -$repository->random(); // get a random object -$repository->random(['author' => 'kevin']); // get a random object filtered by the passed criteria -$repository->randomSet(5); // get 5 random objects -$repository->randomSet(5, ['author' => 'kevin']); // get 5 random objects filtered by the passed criteria -$repository->randomRange(0, 5); // get 0-5 random objects -$repository->randomRange(0, 5, ['author' => 'kevin']); // get 0-5 random objects filtered by the passed criteria - -// instance of ObjectRepository - all returned object(s) are proxied -$repository->find(1); // Proxy|Post|null -$repository->find(['title' => 'My Title']); // Proxy|Post|null -$repository->findOneBy(['title' => 'My Title']); // Proxy|Post|null -$repository->findAll(); // Proxy[]|Post[] -iterator_to_array($repository); // equivalent to above (RepositoryProxy implements \IteratorAggregate) -$repository->findBy(['title' => 'My Title']); // Proxy[]|Post[] - -// can call methods on the underlying repository - returned object(s) are proxied -$repository->findOneByTitle('My Title'); // Proxy|Post|null -``` - -### Assertions - -Both object proxy's and your ModelFactory's have helpful PHPUnit assertions: - -```php -use App\Factory\PostFactory; - -$post = PostFactory::createOne(); - -$post->assertPersisted(); -$post->assertNotPersisted(); - -PostFactory::assert()->empty(); -PostFactory::assert()->count(3); -PostFactory::assert()->countGreaterThan(3); -PostFactory::assert()->countGreaterThanOrEqual(3); -PostFactory::assert()->countLessThan(3); -PostFactory::assert()->countLessThanOrEqual(3); -PostFactory::assert()->exists(['title' => 'My Title']); -PostFactory::assert()->notExists(['title' => 'My Title']); -``` - -### Global State - -If you have an initial database state you want for all tests, you can set this in your `tests/bootstrap.php`: - -```php -// tests/bootstrap.php -// ... - -Zenstruck\Foundry\Test\TestState::addGlobalState(function () { - CategoryFactory::createOne(['name' => 'php']); - CategoryFactory::createOne(['name' => 'symfony']); -}); -``` - -To avoid your bootstrap file from becoming too complex, it is best to wrap your global state into a [Story](#stories): - -```php -// tests/bootstrap.php -// ... - -Zenstruck\Foundry\Test\TestState::addGlobalState(function () { - GlobalStory::load(); -}); -``` - -**NOTES**: -1. You can still access [Story State](#story-state) for *Global State Stories* in your tests and they are still -only loaded once. -2. The [`ResetDatabase`](#enable-foundry-in-your-testcase) trait is required when using global state. - -### PHPUnit Data Providers - -It is possible to use factories in -[PHPUnit data providers](https://phpunit.readthedocs.io/en/9.3/writing-tests-for-phpunit.html#data-providers): - -```php -use App\Factory\PostFactory; - -/** - * @dataProvider postDataProvider - */ -public function test_post_via_data_provider(PostFactory $factory): void -{ - $post = $factory->create(); - - // ... -} - -public static function postDataProvider(): iterable -{ - yield [PostFactory::new()]; - yield [PostFactory::new()->published()]; -} -``` - -**NOTES**: -1. Be sure your data provider returns only instances of `ModelFactory` and you do not try and call `->create()` on them. -Data providers are computed early in the phpunit process before Foundry is booted. -2. For the same reason as above, it is not possible to use [Factory Services](#factories-as-services) with required -constructor arguments (the container is not yet available). - -### Performance - -The following are possible options to improve the speed of your test suite. - -#### DAMADoctrineTestBundle - -This library integrates seamlessly with [DAMADoctrineTestBundle](https://github.com/dmaicher/doctrine-test-bundle) to -wrap each test in a transaction which dramatically reduces test time. This library's test suite runs 5x faster with -this bundle enabled. - -Follow its documentation to install. Foundry's `ResetDatabase` trait detects when using the bundle and adjusts -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 -test suite is run. This could further improve test speed if you have a complex global state. - -#### Miscellaneous - -1. Disable debug mode when running tests. In your `.env.test` file, you can set `APP_DEBUG=0` to have your tests -run without debug mode. This can speed up your tests considerably. You will need to ensure you cache is cleared before -running the test suite. The best place to do this is in your `tests/bootstrap.php`: - - ```php - // tests/bootstrap.php - // ... - if (false === (bool) $_SERVER['APP_DEBUG']) { - // ensure fresh cache - (new Symfony\Component\Filesystem\Filesystem())->remove(__DIR__.'/../var/cache/test'); - } - ``` - -2. Reduce password encoder *work factor*. If you have a lot of tests that work with encoded passwords, this will cause -these tests to be unnecessarily slow. You can improve the speed by reducing the *work factor* of your encoder: - - ```yaml - # config/packages/test/security.yaml - encoders: - # use your user class name here - App\Entity\User: - # This should be the same value as in config/packages/security.yaml - algorithm: auto - cost: 4 # Lowest possible value for bcrypt - time_cost: 3 # Lowest possible value for argon - memory_cost: 10 # Lowest possible value for argon - ``` - -3. Pre-encode user passwords with a known value via `bin/console security:encode-password` and set this in -`ModelFactory::getDefaults()`. Add the known value as a `const` on your factory: - - ```php - class UserFactory extends ModelFactory - { - public const DEFAULT_PASSWORD = '1234'; // the password used to create the pre-encoded version below - - protected function getDefaults(): array - { - return [ - // ... - 'password' => '$argon2id$v=19$m=65536,t=4,p=1$pLFF3D2gnvDmxMuuqH4BrA$3vKfv0cw+6EaNspq9btVAYc+jCOqrmWRstInB2fRPeQ', - ]; - } - } - ``` - - Now, in your tests, when you need access to the unencoded password for a user created with `UserFactory`, use - `UserFactory::DEFAULT_PASSWORD`. - -### Non-Kernel Tests - -Foundry can be used in standard PHPUnit unit tests (TestCase's that just extend `PHPUnit\Framework\TestCase` and not -`Symfony\Bundle\FrameworkBundle\Test\KernelTestCase`). These tests still require using the `Factories` trait to boot -Foundry but will not have doctrine available. Factories created in these tests will not be persisted (calling -[`->withoutPersisting()`](#without-persisting) is not necessary). Because the bundle is not available in these tests, -any bundle configuration you have will not be picked up. You will need to add -[Test-Only Configuration](#test-only-configuration). Unfortunately, this may mean duplicating your bundle configuration -here. - -```php -use App\Factory\PostFactory; -use PHPUnit\Framework\TestCase; -use Zenstruck\Foundry\Test\Factories; - -class MyUnitTest extends TestCase -{ - use Factories; - - public function some_test(): void - { - $post = PostFactory::createOne(); - - // $post is not persisted to the database - } -} -``` - -**NOTE**: [Factories as Services](#factories-as-services) and [Stories as Services](#stories-as-services) with required -constructor arguments are not usable in non-Kernel tests. The container is not available to resolve their dependencies. -The easiest work-around is to make the test an instance of `Symfony\Bundle\FrameworkBundle\Test\KernelTestCase` so the -container is available. - -### Test-Only Configuration - -Foundry can be configured statically, with pure PHP, in your `tests/bootstrap.php`. This is useful if you have a mix -of Kernel and [non-Kernel tests](#non-kernel-tests) or if using Foundry [without the bundle](#using-without-the-bundle): - -```php -// tests/bootstrap.php -// ... - -// 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')); - -// enable auto-refreshing "globally" -Zenstruck\Foundry\Test\TestState::enableDefaultProxyAutoRefresh(); - -// disable auto-refreshing "globally" -Zenstruck\Foundry\Test\TestState::disableDefaultProxyAutoRefresh(); -``` - -**NOTE**: If using [bundle configuration](#bundle-configuration) as well, *test-only configuration* will override the -bundle configuration. - -### Using without the Bundle - -The provided bundle is not strictly required to use Foundry for tests. You can have all your factories, stories, and -configuration live in your `tests/` directory. You can configure foundry with -[Test-Only Configuration](#test-only-configuration). - -## Stories - -Stories are useful if you find your test's *arrange* step is getting complex (loading lots of fixtures) or duplicating -logic between tests and/or your dev fixtures. They are used to extract a specific database *state* into a *story*. -Stories can be loaded in your fixtures and in your tests, they can also depend on other stories. - -Create a story using the maker command: - - $ bin/console make:story Post - -**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 -// src/Story/PostStory.php - -namespace App\Story; - -use App\Factory\CategoryFactory; -use App\Factory\PostFactory; -use App\Factory\TagFactory; -use Zenstruck\Foundry\Story; - -final class PostStory extends Story -{ - public function build(): void - { - // create 10 Category's - CategoryFactory::createMany(10); - - // create 20 Tag's - TagFactory::createMany(20); - - // create 50 Post's - PostFactory::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), - ]; - }); - } -} -``` - -Use the new story in your tests, dev fixtures, or even other stories: - -```php -PostStory::load(); // loads the state defined in PostStory::build() - -PostStory::load(); // does nothing - already loaded -``` - -**NOTE**: Objects persisted in stories are cleared after each test (unless it is a -["Global State Story"](#global-state)). - -### Stories as Services - -If your 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. If not, register the service and tag -with `foundry.story`. - -**NOTE:** The provided bundle is required for stories as services. - -### Story State - -Another feature of *stories* is the ability for them to *remember* the objects they created to be referenced later: - -```php -// src/Story/CategoryStory.php - -namespace App\Story; - -use App\Factory\CategoryFactory; -use Zenstruck\Foundry\Story; - -final class CategoryStory extends Story -{ - public function build(): void - { - $this->add('php', CategoryFactory::createOne(['name' => 'php'])); - - // factories are created when added as state - $this->add('symfony', CategoryFactory::new(['name' => 'symfony'])); - } -} -``` - -Later, you can access the story's state when creating other fixtures: - -```php -PostFactory::createOne(['category' => CategoryStory::load()->get('php')]); - -// or use the magic method (functionally equivalent to above) -PostFactory::createOne(['category' => CategoryStory::php()]); -``` - -**NOTE**: Story state is cleared after each test (unless it is a ["Global State Story"](#global-state)). - -## Bundle Configuration - -Since the bundle is intended to be used in your *dev* and *test* environments, you'll want the configuration -for each environment to match. The easiest way to do this is have your *test* config, import *dev*. This -way, there is just one place to set your config. - -```yaml -# config/packages/dev/zenstruck_foundry.yaml - -zenstruck_foundry: - # ... -``` - -```yaml -# config/packages/test/zenstruck_foundry.yaml - -# just import the dev config -imports: - - { resource: ../dev/zenstruck_foundry.yaml } -``` - -### Full Default Bundle Configuration - -```yaml -zenstruck_foundry: - - # Whether to auto-refresh proxies by default (https://github.com/zenstruck/foundry#auto-refresh) - auto_refresh_proxies: true - - # 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: false - - # Whether or not to allow extra attributes. - allow_extra_attributes: false - - # Whether or not to skip setters and force set object properties (public/private/protected) directly. - always_force_properties: false - - # Customize the instantiator service. - service: null # Example: my_instantiator -``` - -
-You can also use PHP syntax. Click to see how. -
- -Add this line in your `src/Kernel.php`. -```diff - protected function configureContainer(ContainerConfigurator $container): void - { - $container->import('../config/{packages}/*.yaml'); -+ $container->import('../config/{packages}/'.$this->environment.'/*.php'); - $container->import('../config/{packages}/'.$this->environment.'/*.yaml'); - //... - } -``` -And now you are ready to create the `zenstruck_foundry.php` file: -```php -// config/packages/dev/zenstruck_foundry.php - -extension('zenstruck_foundry', [ - - // Whether to auto-refresh proxies by default (https://github.com/zenstruck/foundry#auto-refresh) - 'auto_refresh_proxies' => false, - - // Configure faker to be used by your factories. - 'faker' => [ - - // Change the default faker locale. - 'locale' => null, - - // Customize the faker service. - 'service' => null - ], - - // Configure the default instantiator used by your factories. - 'instantiator' => [ - - // Whether or not to call an object's constructor during instantiation. - 'without_constructor' => false, - - // Whether or not to allow extra attributes. - 'allow_extra_attributes' => false, - - // Whether or not to skip setters and force set object properties (public/private/protected) directly. - 'always_force_properties' => false, - - // Customize the instantiator service. - 'service' => null - ] - ]); -}; -``` -
- +**[Read the Documentation](https://symfony.com/bundles/foundry/current/index.html)** ## Credit diff --git a/bin/build-docs b/bin/build-docs new file mode 100755 index 000000000..a1176b058 --- /dev/null +++ b/bin/build-docs @@ -0,0 +1,4 @@ +#!/bin/sh + +composer bin docs install +bin/tools/docs/vendor/symfony-tools/docs-builder/bin/console build:docs --fail-on-errors docs/ docs/output/ diff --git a/run-tests b/bin/run-tests similarity index 100% rename from run-tests rename to bin/run-tests diff --git a/bin/tools/docs/composer.json b/bin/tools/docs/composer.json new file mode 100644 index 000000000..44a748b79 --- /dev/null +++ b/bin/tools/docs/composer.json @@ -0,0 +1,11 @@ +{ + "require": { + "symfony-tools/docs-builder": "dev-theme" + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/wouterj/docs-builder" + } + ] +} diff --git a/bin/tools/docs/composer.lock b/bin/tools/docs/composer.lock new file mode 100644 index 000000000..9499a9ce7 --- /dev/null +++ b/bin/tools/docs/composer.lock @@ -0,0 +1,1733 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "75a235ecb91072c57bb0a2aa3f596196", + "packages": [ + { + "name": "doctrine/event-manager", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "41370af6a30faa9dc0368c4a6814d596e81aba7f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/41370af6a30faa9dc0368c4a6814d596e81aba7f", + "reference": "41370af6a30faa9dc0368c4a6814d596e81aba7f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/common": "<2.9@dev" + }, + "require-dev": { + "doctrine/coding-standard": "^6.0", + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "lib/Doctrine/Common" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/1.1.x" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2020-05-29T18:28:51+00:00" + }, + { + "name": "doctrine/rst-parser", + "version": "0.4.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/rst-parser.git", + "reference": "b692368c5e275dbda63f5521bc1dae74672933f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/rst-parser/zipball/b692368c5e275dbda63f5521bc1dae74672933f5", + "reference": "b692368c5e275dbda63f5521bc1dae74672933f5", + "shasum": "" + }, + "require": { + "doctrine/event-manager": "^1.0", + "php": "^7.1 || ^8.0", + "symfony/filesystem": "^4.1 || ^5.0", + "symfony/finder": "^4.1 || ^5.0", + "symfony/polyfill-mbstring": "^1.0", + "twig/twig": "^2.9 || ^3.3" + }, + "require-dev": { + "doctrine/coding-standard": "^8.0", + "gajus/dindent": "^2.0.2", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpstan/phpstan-strict-rules": "^0.12", + "phpunit/phpunit": "^7.5 || ^8.0 || ^9.0", + "symfony/css-selector": "4.4 || ^5.2", + "symfony/dom-crawler": "4.4 || ^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\RST\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Passault", + "email": "g.passault@gmail.com", + "homepage": "http://www.gregwar.com/" + }, + { + "name": "Jonathan H. Wage", + "email": "jonwage@gmail.com", + "homepage": "https://jwage.com" + } + ], + "description": "PHP library to parse reStructuredText documents and generate HTML or LaTeX documents.", + "homepage": "https://github.com/doctrine/rst-parser", + "keywords": [ + "html", + "latex", + "markup", + "parser", + "reStructuredText", + "rst" + ], + "support": { + "issues": "https://github.com/doctrine/rst-parser/issues", + "source": "https://github.com/doctrine/rst-parser/tree/0.4.1" + }, + "time": "2021-07-09T20:22:00+00:00" + }, + { + "name": "psr/container", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf", + "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/1.1.1" + }, + "time": "2021-03-05T17:36:06+00:00" + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, + "time": "2021-05-03T11:20:27+00:00" + }, + { + "name": "scrivo/highlight.php", + "version": "v9.18.1.7", + "source": { + "type": "git", + "url": "https://github.com/scrivo/highlight.php.git", + "reference": "05996fcc61e97978d76ca7d1ac14b65e7cd26f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/05996fcc61e97978d76ca7d1ac14b65e7cd26f91", + "reference": "05996fcc61e97978d76ca7d1ac14b65e7cd26f91", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.4" + }, + "require-dev": { + "phpunit/phpunit": "^4.8|^5.7", + "sabberworm/php-css-parser": "^8.3", + "symfony/finder": "^2.8|^3.4", + "symfony/var-dumper": "^2.8|^3.4" + }, + "type": "library", + "autoload": { + "psr-0": { + "Highlight\\": "", + "HighlightUtilities\\": "" + }, + "files": [ + "HighlightUtilities/functions.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Geert Bergman", + "homepage": "http://www.scrivo.org/", + "role": "Project Author" + }, + { + "name": "Vladimir Jimenez", + "homepage": "https://allejo.io", + "role": "Maintainer" + }, + { + "name": "Martin Folkers", + "homepage": "https://twobrain.io", + "role": "Contributor" + } + ], + "description": "Server side syntax highlighter that supports 185 languages. It's a PHP port of highlight.js", + "keywords": [ + "code", + "highlight", + "highlight.js", + "highlight.php", + "syntax" + ], + "support": { + "issues": "https://github.com/scrivo/highlight.php/issues", + "source": "https://github.com/scrivo/highlight.php" + }, + "funding": [ + { + "url": "https://github.com/allejo", + "type": "github" + } + ], + "time": "2021-07-09T00:30:39+00:00" + }, + { + "name": "symfony-tools/docs-builder", + "version": "dev-theme", + "source": { + "type": "git", + "url": "https://github.com/wouterj/docs-builder.git", + "reference": "9fb87655586b9a4c7a84a8825e65d84dc36e0a4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wouterj/docs-builder/zipball/9fb87655586b9a4c7a84a8825e65d84dc36e0a4b", + "reference": "9fb87655586b9a4c7a84a8825e65d84dc36e0a4b", + "shasum": "" + }, + "require": { + "doctrine/rst-parser": "^0.4", + "ext-curl": "*", + "ext-json": "*", + "scrivo/highlight.php": "^9.12.0", + "symfony/console": "^5.2", + "symfony/css-selector": "^5.2", + "symfony/dom-crawler": "^5.2", + "symfony/filesystem": "^5.2", + "symfony/finder": "^5.2", + "symfony/http-client": "^5.2", + "twig/twig": "^2.14 || ^3.3" + }, + "require-dev": { + "gajus/dindent": "^2.0", + "symfony/phpunit-bridge": "^5.2", + "symfony/process": "^5.2" + }, + "type": "project", + "autoload": { + "psr-4": { + "SymfonyDocsBuilder\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "SymfonyDocsBuilder\\Tests\\": "tests" + } + }, + "license": [ + "MIT" + ], + "description": "The build system for Symfony's documentation", + "support": { + "source": "https://github.com/wouterj/docs-builder/tree/theme" + }, + "time": "2021-08-22T11:49:29+00:00" + }, + { + "name": "symfony/console", + "version": "v5.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "51b71afd6d2dc8f5063199357b9880cea8d8bfe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/51b71afd6d2dc8f5063199357b9880cea8d8bfe2", + "reference": "51b71afd6d2dc8f5063199357b9880cea8d8bfe2", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/polyfill-php80": "^1.16", + "symfony/service-contracts": "^1.1|^2", + "symfony/string": "^5.1" + }, + "conflict": { + "psr/log": ">=3", + "symfony/dependency-injection": "<4.4", + "symfony/dotenv": "<5.1", + "symfony/event-dispatcher": "<4.4", + "symfony/lock": "<4.4", + "symfony/process": "<4.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0" + }, + "require-dev": { + "psr/log": "^1|^2", + "symfony/config": "^4.4|^5.0", + "symfony/dependency-injection": "^4.4|^5.0", + "symfony/event-dispatcher": "^4.4|^5.0", + "symfony/lock": "^4.4|^5.0", + "symfony/process": "^4.4|^5.0", + "symfony/var-dumper": "^4.4|^5.0" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/lock": "", + "symfony/process": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v5.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-07-27T19:10:22+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v5.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "7fb120adc7f600a59027775b224c13a33530dd90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/7fb120adc7f600a59027775b224c13a33530dd90", + "reference": "7fb120adc7f600a59027775b224c13a33530dd90", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v5.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-07-21T12:38:00+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5f38c8804a9e97d23e0c8d63341088cd8a22d627", + "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-03-23T23:28:01+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v5.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "2dd8890bd01be59a5221999c05ccf0fcafcb354f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/2dd8890bd01be59a5221999c05ccf0fcafcb354f", + "reference": "2dd8890bd01be59a5221999c05ccf0fcafcb354f", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "^1.16" + }, + "conflict": { + "masterminds/html5": "<2.6" + }, + "require-dev": { + "masterminds/html5": "^2.6", + "symfony/css-selector": "^4.4|^5.0" + }, + "suggest": { + "symfony/css-selector": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v5.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-07-23T15:55:36+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v5.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "343f4fe324383ca46792cae728a3b6e2f708fb32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/343f4fe324383ca46792cae728a3b6e2f708fb32", + "reference": "343f4fe324383ca46792cae728a3b6e2f708fb32", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v5.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-07-21T12:40:44+00:00" + }, + { + "name": "symfony/finder", + "version": "v5.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "17f50e06018baec41551a71a15731287dbaab186" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/17f50e06018baec41551a71a15731287dbaab186", + "reference": "17f50e06018baec41551a71a15731287dbaab186", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v5.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-07-23T15:54:19+00:00" + }, + { + "name": "symfony/http-client", + "version": "v5.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "67c177d4df8601d9a71f9d615c52171c98d22d74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/67c177d4df8601d9a71f9d615c52171c98d22d74", + "reference": "67c177d4df8601d9a71f9d615c52171c98d22d74", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.1", + "symfony/http-client-contracts": "^2.4", + "symfony/polyfill-php73": "^1.11", + "symfony/polyfill-php80": "^1.16", + "symfony/service-contracts": "^1.0|^2" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "2.4" + }, + "require-dev": { + "amphp/amp": "^2.5", + "amphp/http-client": "^4.2.1", + "amphp/http-tunnel": "^1.0", + "amphp/socket": "^1.1", + "guzzlehttp/promises": "^1.4", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/dependency-injection": "^4.4|^5.0", + "symfony/http-kernel": "^4.4.13|^5.1.5", + "symfony/process": "^4.4|^5.0", + "symfony/stopwatch": "^4.4|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-client/tree/v5.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-07-23T15:55:36+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "7e82f6084d7cae521a75ef2cb5c9457bbda785f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/7e82f6084d7cae521a75ef2cb5c9457bbda785f4", + "reference": "7e82f6084d7cae521a75ef2cb5c9457bbda785f4", + "shasum": "" + }, + "require": { + "php": ">=7.2.5" + }, + "suggest": { + "symfony/http-client-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-04-11T23:07:08+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.23.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce", + "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.23.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-02-19T12:13:01+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.23.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "16880ba9c5ebe3642d1995ab866db29270b36535" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/16880ba9c5ebe3642d1995ab866db29270b36535", + "reference": "16880ba9c5ebe3642d1995ab866db29270b36535", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.23.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-05-27T12:26:48+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.23.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8", + "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.23.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-02-19T12:13:01+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.23.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "9174a3d80210dca8daa7f31fec659150bbeabfc6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9174a3d80210dca8daa7f31fec659150bbeabfc6", + "reference": "9174a3d80210dca8daa7f31fec659150bbeabfc6", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.23.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-05-27T12:26:48+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.23.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fba8933c384d6476ab14fb7b8526e5287ca7e010", + "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.23.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-02-19T12:13:01+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.23.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "1100343ed1a92e3a38f9ae122fc0eb21602547be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/1100343ed1a92e3a38f9ae122fc0eb21602547be", + "reference": "1100343ed1a92e3a38f9ae122fc0eb21602547be", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.23.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-07-28T13:41:28+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb", + "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/container": "^1.1" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-04-01T10:43:52+00:00" + }, + { + "name": "symfony/string", + "version": "v5.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1", + "reference": "bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "~1.15" + }, + "require-dev": { + "symfony/error-handler": "^4.4|^5.0", + "symfony/http-client": "^4.4|^5.0", + "symfony/translation-contracts": "^1.1|^2", + "symfony/var-exporter": "^4.4|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "files": [ + "Resources/functions.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v5.3.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-06-27T11:44:38+00:00" + }, + { + "name": "twig/twig", + "version": "v3.3.2", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "21578f00e83d4a82ecfa3d50752b609f13de6790" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/21578f00e83d4a82ecfa3d50752b609f13de6790", + "reference": "21578f00e83d4a82ecfa3d50752b609f13de6790", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "psr/container": "^1.0", + "symfony/phpunit-bridge": "^4.4.9|^5.0.9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.3-dev" + } + }, + "autoload": { + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.3.2" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2021-05-16T12:14:13+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "symfony-tools/docs-builder": 20 + }, + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.1.0" +} diff --git a/composer.json b/composer.json index bbf44c141..ca023aae9 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "zenstruck/callback": "^1.1" }, "require-dev": { + "bamarni/composer-bin-plugin": "^1.4", "dama/doctrine-test-bundle": "^6.0", "doctrine/doctrine-bundle": "^2.0", "doctrine/doctrine-migrations-bundle": "^2.2|^3.0", @@ -49,6 +50,9 @@ "extra": { "branch-alias": { "dev-master": "1.x-dev" + }, + "bamarni-bin": { + "target-directory": "bin/tools" } } } diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..1f0e875f7 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,1739 @@ +Foundry +======= + +Installation +------------ + +.. code-block:: terminal + + $ composer require zenstruck/foundry --dev + +To use the ``make:*`` commands from this bundle, ensure +`Symfony MakerBundle `_ is installed. + +*If not using Symfony Flex, be sure to enable the bundle in your **test**/**dev** environments.* + +Same Entities used in these Docs +-------------------------------- + +For the remainder of the documentation, the following sample entities will be used: + +.. code-block:: php + + namespace App\Entity; + + use Doctrine\ORM\Mapping as ORM; + + /** + * @ORM\Entity(repositoryClass="App\Repository\CategoryRepository") + */ + class Category + { + /** + * @ORM\Id() + * @ORM\GeneratedValue() + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="string", length=255) + */ + private $name; + + public function __construct(string $name) + { + $this->name = $name; + } + + // ... getters/setters + } + +.. code-block:: php + + namespace App\Entity; + + use Doctrine\ORM\Mapping as ORM; + + /** + * @ORM\Entity(repositoryClass="App\Repository\PostRepository") + */ + class Post + { + /** + * @ORM\Id() + * @ORM\GeneratedValue() + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="string", length=255) + */ + private $title; + + /** + * @ORM\Column(type="text", nullable=true) + */ + private $body; + + /** + * @ORM\Column(type="datetime") + */ + private $createdAt; + + /** + * @ORM\Column(type="datetime", nullable=true) + */ + private $publishedAt; + + /** + * @ORM\ManyToOne(targetEntity=Category::class) + * @ORM\JoinColumn + */ + private $category; + + public function __construct(string $title) + { + $this->title = $title; + $this->createdAt = new \DateTime('now'); + } + + // ... getters/setters + } + +Model Factories +--------------- + +The nicest way to use Foundry is to generate one *factory* class per entity. You can skip this +and use `Anonymous Factories`_, but *model factories* give you IDE auto-completion +and access to other useful features. + +Generate +~~~~~~~~ + +Create a model factory for one of your entities with the maker command: + +.. code-block:: terminal + + $ bin/console make:factory + + > Entity class to create a factory for: + > Post + + created: src/Factory/PostFactory.php + + Next: Open your new factory and set default values/states. + +This command will generate a ``PostFactory`` class that looks like this: + +.. code-block:: php + + // src/Factory/PostFactory.php + + namespace App\Factory; + + use App\Entity\Post; + use App\Repository\PostRepository; + use Zenstruck\Foundry\RepositoryProxy; + use Zenstruck\Foundry\ModelFactory; + use Zenstruck\Foundry\Proxy; + + /** + * @extends ModelFactory + * + * @method static Post|Proxy createOne(array $attributes = []) + * @method static Post[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static Post|Proxy find(object|array|mixed $criteria) + * @method static Post|Proxy findOrCreate(array $attributes) + * @method static Post|Proxy first(string $sortedField = 'id') + * @method static Post|Proxy last(string $sortedField = 'id') + * @method static Post|Proxy random(array $attributes = []) + * @method static Post|Proxy randomOrCreate(array $attributes = [])) + * @method static Post[]|Proxy[] all() + * @method static Post[]|Proxy[] findBy(array $attributes) + * @method static Post[]|Proxy[] randomSet(int $number, array $attributes = [])) + * @method static Post[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])) + * @method static PostRepository|RepositoryProxy repository() + * @method Post|Proxy create(array|callable $attributes = []) + */ + final class PostFactory extends ModelFactory + { + public function __construct() + { + parent::__construct(); + + // TODO inject services if required (https://github.com/zenstruck/foundry#factories-as-services) + } + + protected function getDefaults(): array + { + return [ + // TODO add your default values here (https://github.com/zenstruck/foundry#model-factories) + ]; + } + + protected function initialize(): self + { + // see https://github.com/zenstruck/foundry#initialization + return $this + // ->afterInstantiate(function(Post $post) {}) + ; + } + + protected static function getClass(): string + { + return Post::class; + } + } + +.. tip:: + + Using ``make:factory --test`` will generate the factory in ``tests/Factory``. + +.. tip:: + + PhpStorm 2021.2+ has support for + `generics annotations `_, + with it, your factory's annotations can be reduced to the following and still have the same auto-completion + support: + + .. code-block:: php + + /** + * @extends ModelFactory + * + * @method static Post[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static PostRepository|RepositoryProxy repository() + * @method Post|Proxy create(array|callable $attributes = []) + */ + final class PostFactory extends ModelFactory + { + // ... + } + +In the ``getDefaults()``, you can return an array of all default values that any new object +should have. `Faker`_ is available to easily get random data: + +.. code-block:: php + + protected function getDefaults(): array + { + return [ + // Symfony's property-access component is used to populate the properties + // this means that setTitle() will be called or you can have a $title constructor argument + 'title' => self::faker()->unique()->sentence(), + 'body' => self::faker()->sentence(), + ]; + } + +.. tip:: + + It is best to have ``getDefaults()`` return the attributes to persist a valid object + (all non-nullable fields). + +Using your Factory +~~~~~~~~~~~~~~~~~~ + +.. code-block:: php + + use App\Factory\PostFactory; + + // create/persist Post with random data from `getDefaults()` + PostFactory::createOne(); + + // or provide values for some properties (others will be random) + PostFactory::createOne(['title' => 'My Title']); + + // createOne() returns the persisted Post object wrapped in a Proxy object + $post = PostFactory::createOne(); + + // the "Proxy" magically calls the underlying Post methods and is type-hinted to "Post" + $title = $post->getTitle(); // getTitle() can be autocompleted by your IDE! + + // if you need the actual Post object, use ->object() + $realPost = $post->object(); + + // create/persist 5 Posts with random data from getDefaults() + PostFactory::createMany(5); // returns Post[]|Proxy[] + PostFactory::createMany(5, ['title' => 'My Title']); + + // find a persisted object for the given attributes, if not found, create with the attributes + PostFactory::findOrCreate(['title' => 'My Title']); // returns Post|Proxy + + PostFactory::first(); // get the first object (assumes an auto-incremented "id" column) + PostFactory::first('createdAt'); // assuming "createdAt" is a datetime column, this will return latest object + PostFactory::last(); // get the last object (assumes an auto-incremented "id" column) + PostFactory::last('createdAt'); // assuming "createdAt" is a datetime column, this will return oldest object + + PostFactory::truncate(); // empty the database table + + PostFactory::count(); // the number of persisted Posts + + PostFactory::all(); // Post[]|Proxy[] all the persisted Posts + + PostFactory::findBy(['author' => 'kevin']); // Post[]|Proxy[] matching the filter + + $post = PostFactory::find(5); // Post|Proxy with the id of 5 + $post = PostFactory::find(['title' => 'My First Post']); // Post|Proxy matching the filter + + // get a random object that has been persisted + $post = PostFactory::random(); // returns Post|Proxy + $post = PostFactory::random(['author' => 'kevin']); // filter by the passed attributes + + // or automatically persist a new random object if none exists + $post = PostFactory::randomOrCreate(); + $post = PostFactory::randomOrCreate(['author' => 'kevin']); // filter by or create with the passed attributes + + // get a random set of objects that have been persisted + $posts = PostFactory::randomSet(4); // array containing 4 "Post|Proxy" objects + $posts = PostFactory::randomSet(4, ['author' => 'kevin']); // filter by the passed attributes + + // random range of persisted objects + $posts = PostFactory::randomRange(0, 5); // array containing 0-5 "Post|Proxy" objects + $posts = PostFactory::randomRange(0, 5, ['author' => 'kevin']); // filter by the passed attributes + +Reusable Model Factory "States" +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can add any methods you want to your model factories (i.e. static methods that create an object in a certain way) but +you can also add *states*: + +.. code-block:: php + + namespace App\Factory; + + use App\Entity\Post; + use Zenstruck\Foundry\ModelFactory; + + final class PostFactory extends ModelFactory + { + // ... + + public function published(): self + { + // call setPublishedAt() and pass a random DateTime + return $this->addState(['published_at' => self::faker()->dateTime()]); + } + + public function unpublished(): self + { + return $this->addState(['published_at' => null]); + } + + public function withViewCount(int $count = null): self + { + return $this->addState(function () use ($count) { + return ['view_count' => $count ?? self::faker()->numberBetween(0, 10000)]; + }); + } + } + +You can use states to make your tests very explicit to improve readability: + +.. code-block:: php + + // never use the constructor (i.e. "new PostFactory()"), but use the + // "new()" method. After defining the states, call "create()" to create + // and persist the model. + $post = PostFactory::new()->unpublished()->create(); + $post = PostFactory::new()->withViewCount(3)->create(); + + // combine multiple states + $post = PostFactory::new() + ->unpublished() + ->withViewCount(10) + ->create() + ; + + // states that don't require arguments can be added as strings to PostFactory::new() + $post = PostFactory::new('published', 'withViewCount')->create(); + +Attributes +~~~~~~~~~~ + +The attributes used to instantiate the object can be added several ways. Attributes can be an *array*, or a *callable* +that returns an array. Using a *callable* ensures random data as the callable is run for each object separately during +instantiation. + +.. code-block:: php + + use App\Entity\Category; + use App\Entity\Post; + use App\Factory\CategoryFactory; + use App\Factory\PostFactory; + use function Zenstruck\Foundry\faker; + + // The first argument to "new()" allows you to overwrite the default + // values that are defined in the `PostFactory::getDefaults()` + $posts = PostFactory::new(['title' => 'Post A']) + ->withAttributes([ + 'body' => 'Post Body...', + + // CategoryFactory will be used to create a new Category for each Post + 'category' => CategoryFactory::new(['name' => 'php']), + ]) + ->withAttributes([ + // Proxies are automatically converted to their wrapped object + 'category' => CategoryFactory::createOne(), + ]) + ->withAttributes(function() { return ['createdAt' => faker()->dateTime()]; }) // see faker section below + + // create "2" Post's + ->many(2)->create(['title' => 'Different Title']) + ; + + $posts[0]->getTitle(); // "Different Title" + $posts[0]->getBody(); // "Post Body..." + $posts[0]->getCategory(); // random Category + $posts[0]->getPublishedAt(); // \DateTime('last week') + $posts[0]->getCreatedAt(); // random \DateTime + + $posts[1]->getTitle(); // "Different Title" + $posts[1]->getBody(); // "Post Body..." + $posts[1]->getCategory(); // random Category (different than above) + $posts[1]->getPublishedAt(); // \DateTime('last week') + $posts[1]->getCreatedAt(); // random \DateTime (different than above) + +Faker +~~~~~ + +This library provides a wrapper for `FakerPHP `_ to help with generating random data for your factories: + +.. code-block:: php + + use Zenstruck\Foundry\Factory; + use function Zenstruck\Foundry\faker; + + Factory::faker()->name(); // random name + + // alternatively, use the helper function + faker()->email(); // random email + +.. note:: + + You can register your own ``Faker\Generator``: + + .. code-block:: yaml + + # config/packages/dev/zenstruck_foundry.yaml (see Bundle Configuration section about sharing this in the test environment) + zenstruck_foundry: + faker: + locale: fr_FR # set the locale + # or + service: my_faker # use your own instance of Faker\Generator for complete control + +Events / Hooks +~~~~~~~~~~~~~~ + +The following events can be added to factories. Multiple event callbacks can be added, they are run in the order +they were added. + +.. code-block:: php + + use App\Factory\PostFactory; + use Zenstruck\Foundry\Proxy; + + PostFactory::new() + ->beforeInstantiate(function(array $attributes): array { + // $attributes is what will be used to instantiate the object, manipulate as required + $attributes['title'] = 'Different title'; + + return $attributes; // must return the final $attributes + }) + ->afterInstantiate(function(Post $object, array $attributes): void { + // $object is the instantiated object + // $attributes contains the attributes used to instantiate the object and any extras + }) + ->afterPersist(function(Proxy $object, array $attributes) { + /* @var Post $object */ + // this event is only called if the object was persisted + // $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 + }) + + // multiple events are allowed + ->beforeInstantiate(function($attributes) { return $attributes; }) + ->afterInstantiate(function() {}) + ->afterPersist(function() {}) + ; + +You can also add hooks directly in your model factory class: + +.. code-block:: php + + protected function initialize(): self + { + return $this + ->afterPersist(function() {}) + ; + } + +Read `Initialization`_ to learn more about the ``initialize()`` method. + +Initialization +~~~~~~~~~~~~~~ + +You can override your model factory's ``initialize()`` method to add default state/logic: + +.. code-block:: php + + namespace App\Factory; + + use App\Entity\Post; + use Zenstruck\Foundry\ModelFactory; + + final class PostFactory extends ModelFactory + { + // ... + + protected function initialize(): self + { + return $this + ->published() // published by default + ->instantiateWith(function (array $attributes) { + return new Post(); // custom instantiation for this factory + }) + ->afterPersist(function () {}) // default event for this factory + ; + } + } + +.. note:: + + Be sure to chain the states/hooks off of ``$this`` because factories are `Immutable`_. + +.. _instantiation: + +Instantiation +~~~~~~~~~~~~~ + +By default, objects are instantiated in the normal fashion, by using the object's constructor. Attributes +that match constructor arguments are used. Remaining attributes are set to the object using Symfony's +`PropertyAccess `_ component +(setters/public properties). Any extra attributes cause an exception to be thrown. + +You can customize the instantiator in several ways: + +.. code-block:: php + + use App\Entity\Post; + use App\Factory\PostFactory; + use Zenstruck\Foundry\Instantiator; + + // set the instantiator for the current factory + PostFactory::new() + // instantiate the object without calling the constructor + ->instantiateWith((new Instantiator())->withoutConstructor()) + + // "foo" and "bar" attributes are ignored when instantiating + ->instantiateWith((new Instantiator())->allowExtraAttributes(['foo', 'bar'])) + + // all extra attributes are ignored when instantiating + ->instantiateWith((new Instantiator())->allowExtraAttributes()) + + // force set "title" and "body" when instantiating + ->instantiateWith((new Instantiator())->alwaysForceProperties(['title', 'body'])) + + // never use setters, always "force set" properties (even private/protected, does not use setter) + ->instantiateWith((new Instantiator())->alwaysForceProperties()) + + // can combine the different "modes" + ->instantiateWith((new Instantiator())->withoutConstructor()->allowExtraAttributes()->alwaysForceProperties()) + + // the instantiator is just a callable, you can provide your own + ->instantiateWith(function(array $attibutes, string $class): object { + return new Post(); // ... your own logic + }) + ; + +You can customize the instantiator globally for all your factories (can still be overruled by factory instance +instantiators): + +.. code-block:: yaml + + # config/packages/dev/zenstruck_foundry.yaml (see Bundle Configuration section about sharing this in the test environment) + 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 +~~~~~~~~~ + +Factory's are immutable: + +.. code-block:: php + + use App\Factory\PostFactory; + + $factory = PostFactory::new(); + $factory1 = $factory->withAttributes([]); // returns a new PostFactory object + $factory2 = $factory->instantiateWith(function () {}); // returns a new PostFactory object + $factory3 = $factory->beforeInstantiate(function () {}); // returns a new PostFactory object + $factory4 = $factory->afterInstantiate(function () {}); // returns a new PostFactory object + $factory5 = $factory->afterPersist(function () {}); // returns a new PostFactory object + +Doctrine Relationships +~~~~~~~~~~~~~~~~~~~~~~ + +Assuming your entites follow the +`best practices for Doctrine Relationships `_ and you are +using the :ref:`default instantiator `, Foundry *just works* with doctrine relationships. There are some +nuances with the different relationships and how entities are created. The following tries to document these for +each relationship type. + +Many-to-One +........... + +The following assumes the ``Comment`` entity has a many-to-one relationship with ``Post``: + +.. code-block:: php + + use App\Factory\CommentFactory; + use App\Factory\PostFactory; + + // Example 1: pre-create Post and attach to Comment + $post = PostFactory::createOne(); // instance of Proxy + + CommentFactory::createOne(['post' => $post]); + CommentFactory::createOne(['post' => $post->object()]); // functionally the same as above + + // Example 2: pre-create Posts and choose a random one + PostFactory::createMany(5); // create 5 Posts + + CommentFactory::createOne(['post' => PostFactory::random()]); + + // or create many, each with a different random Post + CommentFactory::createMany( + 5, // create 5 comments + function() { // note the callback - this ensures that each of the 5 comments has a different Post + return ['post' => PostFactory::random()]; // each comment set to a random Post from those already in the database + } + ); + + // Example 3: create a separate Post for each Comment + CommentFactory::createMany(5, [ + // this attribute is an instance of PostFactory that is created separately for each Comment created + 'post' => PostFactory::new(), + ]); + + // Example 4: create multiple Comments with the same Post + CommentFactory::createMany(5, [ + 'post' => PostFactory::createOne(), // note the "createOne()" here + ]); + +.. tip:: + + It is recommended that the only relationship you define in ``ModelFactory::getDefaults()`` is non-null + Many-to-One's. + +.. tip:: + + It is also recommended that your ``ModelFactory::getDefaults()`` return a ``Factory`` and not the created entity: + + .. code-block:: php + + protected function getDefaults(): array + { + return [ + // RECOMMENDED + 'post' => PostFactory::new(), + 'post' => PostFactory::new()->published(), + + // NOT RECOMMENDED - will potentially result in extra unintended Posts + 'post' => PostFactory::createOne(), + 'post' => PostFactory::new()->published()->create(), + ]; + } + +One-to-Many +........... + +The following assumes the ``Post`` entity has a one-to-many relationship with ``Comment``: + +.. code-block:: php + + use App\Factory\CommentFactory; + use App\Factory\PostFactory; + + // Example 1: Create a Post with 6 Comments + PostFactory::createOne(['comments' => CommentFactory::new()->many(6)]); + + // Example 2: Create 6 Posts each with 4 Comments (24 Comments total) + PostFactory::createMany(6, ['comments' => CommentFactory::new()->many(4)]); + + // Example 3: Create 6 Posts each with between 0 and 10 Comments + PostFactory::createMany(6, ['comments' => CommentFactory::new()->many(0, 10)]); + +Many-to-Many +............ + +The following assumes the ``Post`` entity has a many-to-many relationship with ``Tag``: + +.. code-block:: php + + use App\Factory\PostFactory; + use App\Factory\TagFactory; + + // Example 1: pre-create Tags and attach to Post + $tags = TagFactory::createMany(3); + + PostFactory::createOne(['tags' => $tags]); + + // Example 2: pre-create Tags and choose a random set + TagFactory::createMany(10); + + PostFactory::new() + ->many(5) // create 5 posts + ->create(function() { // note the callback - this ensures that each of the 5 posts has a different random set + return ['tags' => TagFactory::randomSet(2)]; // each post uses 2 random tags from those already in the database + }) + ; + + // Example 3: pre-create Tags and choose a random range + TagFactory::createMany(10); + + PostFactory::new() + ->many(5) // create 5 posts + ->create(function() { // note the callback - this ensures that each of the 5 posts has a different random range + return ['tags' => TagFactory::randomRange(0, 5)]; // each post uses between 0 and 5 random tags from those already in the database + }) + ; + + // Example 4: create 3 Posts each with 3 unique Tags + PostFactory::createMany(3, ['tags' => TagFactory::new()->many(3)]); + + // Example 5: create 3 Posts each with between 0 and 3 unique Tags + PostFactory::createMany(3, ['tags' => TagFactory::new()->many(0, 3)]); + +Factories as Services +~~~~~~~~~~~~~~~~~~~~~ + +If your factories require dependencies, you can define them as a service. The following example demonstrates a very +common use-case: encoding a password with the ``UserPasswordEncoderInterface`` service. + +.. code-block:: php + + // src/Factory/UserFactory.php + + namespace App\Factory; + + use App\Entity\User; + use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; + use Zenstruck\Foundry\ModelFactory; + + final class UserFactory extends ModelFactory + { + private $passwordEncoder; + + public function __construct(UserPasswordEncoderInterface $passwordEncoder) + { + parent::__construct(); + + $this->passwordEncoder = $passwordEncoder; + } + + protected function getDefaults(): array + { + return [ + 'email' => self::faker()->unique()->safeEmail(), + 'password' => '1234', + ]; + } + + protected function initialize(): self + { + return $this + ->afterInstantiate(function(User $user) { + $user->setPassword($this->passwordEncoder->encodePassword($user, $user->getPassword())); + }) + ; + } + + protected static function getClass(): string + { + return User::class; + } + } + +If using a standard Symfony Flex app, this will be autowired/autoconfigured. If not, register the service and tag +with ``foundry.factory``. + +Use the factory as normal: + +.. code-block:: php + + UserFactory::createOne(['password' => 'mypass'])->getPassword(); // "mypass" encoded + UserFactory::createOne()->getPassword(); // "1234" encoded (because "1234" is set as the default password) + +.. note:: + + The provided bundle is required for factories as services. + +.. note:: + + If using ``make:factory --test``, factories will be created in the ``tests/Factory`` directory which is not + autowired/autoconfigured in a standard Symfony Flex app. You will have to manually register these as + services. + +Anonymous Factories +~~~~~~~~~~~~~~~~~~~ + +Foundry can be used to create factories for entities that you don't have model factories for: + +.. code-block:: php + + use App\Entity\Post; + use Zenstruck\Foundry\AnonymousFactory; + use function Zenstruck\Foundry\factory; + use function Zenstruck\Foundry\create; + use function Zenstruck\Foundry\create_many; + + $factory = AnonymousFactory::new(Post::class); + $factory = factory(Post::class); // alternative to above + + // has the same API as ModelFactory's + $factory->create(['field' => 'value']); + $factory->many(5)->create(['field' => 'value']); + $factory->instantiateWith(function () {}); + $factory->beforeInstantiate(function () {}); + $factory->afterInstantiate(function () {}); + $factory->afterPersist(function () {}); + + // find a persisted object for the given attributes, if not found, create with the attributes + $factory->findOrCreate(['title' => 'My Title']); + + $factory->first(); // get the first object (assumes an auto-incremented "id" column) + $factory->first('createdAt'); // assuming "createdAt" is a datetime column, this will return latest object + $factory->last(); // get the last object (assumes an auto-incremented "id" column) + $factory->last('createdAt'); // assuming "createdAt" is a datetime column, this will return oldest object + + $factory->truncate(); // empty the database table + $factory->count(); // the number of persisted Post's + $factory->all(); // Post[]|Proxy[] all the persisted Post's + + $factory->findBy(['author' => 'kevin']); // Post[]|Proxy[] matching the filter + + $factory->find(5); // Post|Proxy with the id of 5 + $factory->find(['title' => 'My First Post']); // Post|Proxy matching the filter + + // get a random object that has been persisted + $factory->random(); // returns Post|Proxy + $factory->random(['author' => 'kevin']); // filter by the passed attributes + + // or automatically persist a new random object if none exists + $factory->randomOrCreate(); + $factory->randomOrCreate(['author' => 'kevin']); // filter by or create with the passed attributes + + // get a random set of objects that have been persisted + $factory->randomSet(4); // array containing 4 "Post|Proxy" objects + $factory->randomSet(4, ['author' => 'kevin']); // filter by the passed attributes + + // random range of persisted objects + $factory->randomRange(0, 5); // array containing 0-5 "Post|Proxy" objects + $factory->randomRange(0, 5, ['author' => 'kevin']); // filter by the passed attributes + + // repository proxy wrapping PostRepository (see Repository Proxy section below) + $factory->repository(); + + // convenience functions + $entity = create(Post::class, ['field' => 'value']); + $entities = create_many(Post::class, 5, ['field' => 'value']); + +.. _without-persisting: + +Without Persisting +~~~~~~~~~~~~~~~~~~ + +Factories can also create objects without persisting them. This can be useful for unit tests where you just want to test +the behaviour of the actual object or for creating objects that are not entities. When created, they are still wrapped +in a ``Proxy`` to optionally save later. + +.. code-block:: php + + use App\Factory\PostFactory; + use App\Entity\Post; + use Zenstruck\Foundry\AnonymousFactory; + use function Zenstruck\Foundry\instantiate; + use function Zenstruck\Foundry\instantiate_many; + + $post = PostFactory::new()->withoutPersisting()->create(); // returns Post|Proxy + $post->setTitle('something else'); // do something with object + $post->save(); // persist the Post (save() is a method on Proxy) + + $post = PostFactory::new()->withoutPersisting()->create()->object(); // actual Post object + + $posts = PostFactory::new()->withoutPersisting()->many(5)->create(); // returns Post[]|Proxy[] + + // anonymous factories: + $factory = new AnonymousFactory(Post::class); + + $entity = $factory->withoutPersisting()->create(['field' => 'value']); // returns Post|Proxy + + $entity = $factory->withoutPersisting()->create(['field' => 'value'])->object(); // actual Post object + + $entities = $factory->withoutPersisting()->many(5)->create(['field' => 'value']); // returns Post[]|Proxy[] + + // convenience functions + $entity = instantiate(Post::class, ['field' => 'value']); + $entities = instantiate_many(Post::class, 5, ['field' => 'value']); + +If you'd like your model factory to not persist by default, override its ``initialize()`` method to add this behaviour: + +.. code-block:: php + + protected function initialize(): self + { + return $this + ->withoutPersisting() + ; + } + +Now, after creating objects using this factory, you'd have to call ``->save()`` to actually persist them to the database. + +.. tip:: + + If you'd like to disable persisting by default for all your model factories: + + 1. Create an abstract model factory that extends ``Zenstruck\Foundry\ModelFactory``. + 2. Override the ``initialize()`` method as shown above. + 3. Have all your model factories extend from this. + +Using with DoctrineFixturesBundle +--------------------------------- + +Foundry works out of the box with `DoctrineFixturesBundle `_. +You can simply use your factories and stories right within your fixture files: + +.. code-block:: php + + // src/DataFixtures/AppFixtures.php + namespace App\DataFixtures; + + use App\Factory\CategoryFactory; + use App\Factory\CommentFactory; + use App\Factory\PostFactory; + use App\Factory\TagFactory; + use Doctrine\Bundle\FixturesBundle\Fixture; + use Doctrine\Persistence\ObjectManager; + + class AppFixtures extends Fixture + { + public function load(ObjectManager $manager) + { + // create 10 Category's + CategoryFactory::createMany(10); + + // create 20 Tag's + TagFactory::createMany(20); + + // create 50 Post's + PostFactory::createMany(50, function() { + return [ + // each Post will have a random Category (chosen from those created above) + 'category' => CategoryFactory::random(), + + // each Post will have between 0 and 6 Tag's (chosen from those created above) + 'tags' => TagFactory::randomRange(0, 6), + + // each Post will have between 0 and 10 Comment's that are created new + 'comments' => CommentFactory::new()->many(0, 10), + ]; + }); + } + } + +Run the ``doctrine:fixtures:load`` as normal to seed your database. + +Using in your Tests +------------------- + +Traditionally, data fixtures are defined in one or more files outside of your tests. When writing tests using these +fixtures, your fixtures are a sort of a *black box*. There is no clear connection between the fixtures and what you +are testing. + +Foundry allows each individual test to fully follow the `AAA `_ +("Arrange", "Act", "Assert") testing pattern. You create your fixtures using "factories" at the beginning of each test. +You only create fixtures that are applicable for the test. Additionally, these fixtures are created with only the +attributes required for the test - attributes that are not applicable are filled with random data. The created fixture +objects are wrapped in a "proxy" that helps with pre and post assertions. + +Let's look at an example: + +.. code-block:: php + + public function test_can_post_a_comment(): void + { + // 1. "Arrange" + $post = PostFactory::new() // New Post factory + ->published() // Make the post in a "published" state + ->create([ // Instantiate Post object and persist + 'slug' => 'post-a' // This test only requires the slug field - all other fields are random data + ]) + ; + + // 1a. "Pre-Assertions" + $this->assertCount(0, $post->getComments()); + + // 2. "Act" + static::ensureKernelShutdown(); // creating factories boots the kernel; shutdown before creating the client + $client = static::createClient(); + $client->request('GET', '/posts/post-a'); // Note the slug from the arrange step + $client->submitForm('Add', [ + 'comment[name]' => 'John', + 'comment[body]' => 'My comment', + ]); + + // 3. "Assert" + self::assertResponseRedirects('/posts/post-a'); + + $this->assertCount(1, $post->refresh()->getComments()); // Refresh $post from the database and call ->getComments() + + CommentFactory::assert()->exists([ // Doctrine repository assertions + 'name' => 'John', + 'body' => 'My comment', + ]); + } + +.. _enable-foundry-in-your-testcase: + +Enable Foundry in your TestCase +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Add the ``Factories`` trait for tests using factories: + +.. code-block:: php + + use App\Factory\PostFactory; + use Zenstruck\Foundry\Test\Factories; + use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + + class MyTest extends WebTestCase + { + use Factories; + + public function test_1(): void + { + $post = PostFactory::createOne(); + + // ... + } + } + +Database Reset +~~~~~~~~~~~~~~ + +This library requires that your database be reset before each test. The packaged ``ResetDatabase`` trait handles +this for you. + +.. code-block:: php + + use Zenstruck\Foundry\Test\Factories; + use Zenstruck\Foundry\Test\ResetDatabase; + use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + + class MyTest extends WebTestCase + { + use ResetDatabase, Factories; + + // ... + } + +Before the first test using the ``ResetDatabase`` trait, it drops (if exists) and creates the test database. +Then, by default, before each test, it resets the schema using ``doctrine:schema:drop``/``doctrine:schema:create``. + +Alternatively, you can have it run your migrations instead by setting the env variable ``FOUNDRY_RESET_MODE=migrate`` +(in your ``.env.test``). When using this *mode*, before each test, the database is dropped/created and your migrations +run (via ``doctrine:migrations:migrate``). This mode can really make your test suite slow (especially if you have a lot +of migrations). It is highly recommended to use `DamaDoctrineTestBundle`_ to improve the +speed. When this bundle is enabled, the database is dropped/created and migrated only once for the suite. + +.. tip:: + + Create a base TestCase for tests using factories to avoid adding the traits to every TestCase. + +.. tip:: + + If your tests :ref:`are not persisting ` the objects they create, these test traits are not + required. + +By default, ``ResetDatabase`` resets the default configured connection's database and default configured object manager's +schema. To customize the connection's and object manager's to be reset (or reset multiple connections/managers), set the +following environment variables: + +.. code-block:: bash + + # .env.test + + FOUNDRY_RESET_CONNECTIONS=connection1,connection2 + FOUNDRY_RESET_OBJECT_MANAGERS=manager1,manager2 + +Object Proxy +~~~~~~~~~~~~ + +Objects created by a factory are wrapped in a special *Proxy* object. These objects allow your doctrine entities +to have `Active Record `_ *like* behavior: + +.. code-block:: php + + use App\Factory\PostFactory; + + $post = PostFactory::createOne()->create(['title' => 'My Title']); // instance of Zenstruck\Foundry\Proxy + + // get the wrapped object + $realPost = $post->object(); // instance of Post + + // call any Post method + $post->getTitle(); // "My Title" + + // set property and save to the database + $post->setTitle('New Title'); + $post->save(); + + // refresh from the database + $post->refresh(); + + // delete from the database + $post->remove(); + + $post->repository(); // repository proxy wrapping PostRepository (see Repository Proxy section below) + +Force Setting +............. + +Object proxies have helper methods to access non-public properties of the object they wrap: + +.. code-block:: php + + // set private/protected properties + $post->forceSet('createdAt', new \DateTime()); + + // get private/protected properties + $post->forceGet('createdAt'); + +Auto-Refresh +............ + +Object proxies have the option to enable *auto refreshing* that removes the need to call ``->refresh()`` before calling +methods on the underlying object. When auto-refresh is enabled, most calls to proxy objects first refresh the wrapped +object from the database. + +.. code-block:: php + + use App\Factory\PostFactory; + + $post = PostFactory::new(['title' => 'Original Title']) + ->create() + ->enableAutoRefresh() + ; + + // ... logic that changes the $post title to "New Title" (like your functional test) + + $post->getTitle(); // "New Title" (equivalent to $post->refresh()->getTitle()) + +Without auto-refreshing enabled, the above call to ``$post->getTitle()`` would return "Original Title". + +.. note:: + + A situation you need to be aware of when using auto-refresh is that all methods refresh the object first. If + changing the object's state via multiple methods (or multiple force-sets), an "unsaved changes" exception will be + thrown: + + .. code-block:: php + + use App\Factory\PostFactory; + + $post = PostFactory::new(['title' => 'Original Title', 'body' => 'Original Body']) + ->create() + ->enableAutoRefresh() + ; + + $post->setTitle('New Title'); + $post->setBody('New Body'); // exception thrown because of "unsaved changes" to $post from above + + To overcome this, you need to first disable auto-refreshing, then re-enable after making/saving the changes: + + .. code-block:: php + + use App\Entity\Post; + use App\Factory\PostFactory; + + $post = PostFactory::new(['title' => 'Original Title', 'body' => 'Original Body']) + ->create() + ->enableAutoRefresh() + ; + + $post->disableAutoRefresh(); + $post->setTitle('New Title'); // or using ->forceSet('title', 'New Title') + $post->setBody('New Body'); // or using ->forceSet('body', 'New Body') + $post->enableAutoRefresh(); + $post->save(); + + $post->getBody(); // "New Body" + $post->getTitle(); // "New Title" + + // alternatively, use the ->withoutAutoRefresh() helper which first disables auto-refreshing, then re-enables after + // executing the callback. + $post->withoutAutoRefresh(function (Post $post) { // can pass either Post or Proxy to the callback + $post->setTitle('New Title'); + $post->setBody('New Body'); + }); + $post->save(); + + // if force-setting properties, you can use the ->forceSetAll() helper: + $post->forceSetAll([ + 'title' => 'New Title', + 'body' => 'New Body', + ]); + $post->save(); + +.. note:: + + You can enable/disable auto-refreshing globally to have every proxy auto-refreshable by default or not. When + enabled, you will have to *opt-out* of auto-refreshing. + + .. configuration-block:: + + .. code-block:: yaml + + # config/packages/dev/zenstruck_foundry.yaml (see Bundle Configuration section about sharing this in the test environment) + zenstruck_foundry: + auto_refresh_proxies: true/false + +Repository Proxy +~~~~~~~~~~~~~~~~ + +This library provides a *Repository Proxy* that wraps your object repositories to provide useful assertions and methods: + +.. code-block:: php + + use App\Entity\Post; + use App\Factory\PostFactory; + use function Zenstruck\Foundry\repository; + + // instance of RepositoryProxy that wraps PostRepository + $repository = PostFactory::repository(); + + // alternative to above for proxying repository you haven't created model factories for + $repository = repository(Post::class); + + // helpful methods - all returned object(s) are proxied + $repository->count(); // number of rows in the database table + count($repository); // equivalent to above (RepositoryProxy implements \Countable) + $repository->first(); // get the first object (assumes an auto-incremented "id" column) + $repository->first('createdAt'); // assuming "createdAt" is a datetime column, this will return latest object + $repository->last(); // get the last object (assumes an auto-incremented "id" column) + $repository->last('createdAt'); // assuming "createdAt" is a datetime column, this will return oldest object + $repository->truncate(); // delete all rows in the database table + $repository->random(); // get a random object + $repository->random(['author' => 'kevin']); // get a random object filtered by the passed criteria + $repository->randomSet(5); // get 5 random objects + $repository->randomSet(5, ['author' => 'kevin']); // get 5 random objects filtered by the passed criteria + $repository->randomRange(0, 5); // get 0-5 random objects + $repository->randomRange(0, 5, ['author' => 'kevin']); // get 0-5 random objects filtered by the passed criteria + + // instance of ObjectRepository - all returned object(s) are proxied + $repository->find(1); // Proxy|Post|null + $repository->find(['title' => 'My Title']); // Proxy|Post|null + $repository->findOneBy(['title' => 'My Title']); // Proxy|Post|null + $repository->findAll(); // Proxy[]|Post[] + iterator_to_array($repository); // equivalent to above (RepositoryProxy implements \IteratorAggregate) + $repository->findBy(['title' => 'My Title']); // Proxy[]|Post[] + + // can call methods on the underlying repository - returned object(s) are proxied + $repository->findOneByTitle('My Title'); // Proxy|Post|null + +Assertions +~~~~~~~~~~ + +Both object proxy's and your ModelFactory's have helpful PHPUnit assertions: + +.. code-block:: php + + use App\Factory\PostFactory; + + $post = PostFactory::createOne(); + + $post->assertPersisted(); + $post->assertNotPersisted(); + + PostFactory::assert()->empty(); + PostFactory::assert()->count(3); + PostFactory::assert()->countGreaterThan(3); + PostFactory::assert()->countGreaterThanOrEqual(3); + PostFactory::assert()->countLessThan(3); + PostFactory::assert()->countLessThanOrEqual(3); + PostFactory::assert()->exists(['title' => 'My Title']); + PostFactory::assert()->notExists(['title' => 'My Title']); + +.. _global-state: + +Global State +~~~~~~~~~~~~ + +If you have an initial database state you want for all tests, you can set this in your ``tests/bootstrap.php``: + +.. code-block:: php + + // tests/bootstrap.php + // ... + + Zenstruck\Foundry\Test\TestState::addGlobalState(function () { + CategoryFactory::createOne(['name' => 'php']); + CategoryFactory::createOne(['name' => 'symfony']); + }); + +To avoid your bootstrap file from becoming too complex, it is best to wrap your global state into a +:ref:`Story `: + +.. code-block:: php + + // tests/bootstrap.php + // ... + + Zenstruck\Foundry\Test\TestState::addGlobalState(function () { + GlobalStory::load(); + }); + +.. note:: + + You can still access `Story State`_ for *Global State Stories* in your tests and they are still + only loaded once. + +.. note:: + + The :ref:`ResetDatabase ` trait is required when using global state. + +PHPUnit Data Providers +~~~~~~~~~~~~~~~~~~~~~~ + +It is possible to use factories in +`PHPUnit data providers `_: + +.. code-block:: php + + use App\Factory\PostFactory; + + /** + * @dataProvider postDataProvider + */ + public function test_post_via_data_provider(PostFactory $factory): void + { + $post = $factory->create(); + + // ... + } + + public static function postDataProvider(): iterable + { + yield [PostFactory::new()]; + yield [PostFactory::new()->published()]; + } + +.. note:: + + Be sure your data provider returns only instances of ``ModelFactory`` and you do not try and call ``->create()`` on them. + Data providers are computed early in the phpunit process before Foundry is booted. + +.. note:: + + For the same reason as above, it is not possible to use `Factories as Services`_ with required + constructor arguments (the container is not yet available). + +Performance +~~~~~~~~~~~ + +The following are possible options to improve the speed of your test suite. + +DAMADoctrineTestBundle +...................... + +This library integrates seamlessly with `DAMADoctrineTestBundle `_ to +wrap each test in a transaction which dramatically reduces test time. This library's test suite runs 5x faster with +this bundle enabled. + +Follow its documentation to install. Foundry's ``ResetDatabase`` trait detects when using the bundle and adjusts +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`_, 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 +............. + +1. Disable debug mode when running tests. In your ``.env.test`` file, you can set ``APP_DEBUG=0`` to have your tests + run without debug mode. This can speed up your tests considerably. You will need to ensure you cache is cleared + before running the test suite. The best place to do this is in your ``tests/bootstrap.php``: + + .. code-block:: php + + // tests/bootstrap.php + // ... + if (false === (bool) $_SERVER['APP_DEBUG']) { + // ensure fresh cache + (new Symfony\Component\Filesystem\Filesystem())->remove(__DIR__.'/../var/cache/test'); + } + +2. Reduce password encoder *work factor*. If you have a lot of tests that work with encoded passwords, this will cause + these tests to be unnecessarily slow. You can improve the speed by reducing the *work factor* of your encoder: + + .. code-block:: yaml + + # config/packages/test/security.yaml + encoders: + # use your user class name here + App\Entity\User: + # This should be the same value as in config/packages/security.yaml + algorithm: auto + cost: 4 # Lowest possible value for bcrypt + time_cost: 3 # Lowest possible value for argon + memory_cost: 10 # Lowest possible value for argon + +3. Pre-encode user passwords with a known value via ``bin/console security:encode-password`` and set this in + ``ModelFactory::getDefaults()``. Add the known value as a ``const`` on your factory: + + .. code-block:: php + + class UserFactory extends ModelFactory + { + public const DEFAULT_PASSWORD = '1234'; // the password used to create the pre-encoded version below + + protected function getDefaults(): array + { + return [ + // ... + 'password' => '$argon2id$v=19$m=65536,t=4,p=1$pLFF3D2gnvDmxMuuqH4BrA$3vKfv0cw+6EaNspq9btVAYc+jCOqrmWRstInB2fRPeQ', + ]; + } + } + + Now, in your tests, when you need access to the unencoded password for a user created with ``UserFactory``, use + ``UserFactory::DEFAULT_PASSWORD``. + +Non-Kernel Tests +~~~~~~~~~~~~~~~~ + +Foundry can be used in standard PHPUnit unit tests (TestCase's that just extend ``PHPUnit\Framework\TestCase`` and not +``Symfony\Bundle\FrameworkBundle\Test\KernelTestCase``). These tests still require using the ``Factories`` trait to boot +Foundry but will not have doctrine available. Factories created in these tests will not be persisted (calling +``->withoutPersisting()`` is not necessary). Because the bundle is not available in these tests, +any bundle configuration you have will not be picked up. You will need to add +`Test-Only Configuration`_. Unfortunately, this may mean duplicating your bundle configuration +here. + +.. code-block:: php + + use App\Factory\PostFactory; + use PHPUnit\Framework\TestCase; + use Zenstruck\Foundry\Test\Factories; + + class MyUnitTest extends TestCase + { + use Factories; + + public function some_test(): void + { + $post = PostFactory::createOne(); + + // $post is not persisted to the database + } + } + +.. note:: + + `Factories as Services`_ and `Stories as Services`_ with required + constructor arguments are not usable in non-Kernel tests. The container is not available to resolve their dependencies. + The easiest work-around is to make the test an instance of ``Symfony\Bundle\FrameworkBundle\Test\KernelTestCase`` so the + container is available. + +Test-Only Configuration +~~~~~~~~~~~~~~~~~~~~~~~ + +Foundry can be configured statically, with pure PHP, in your ``tests/bootstrap.php``. This is useful if you have a mix +of Kernel and `non-Kernel tests`_ or if `Using Without the Bundle`_: + +.. code-block:: php + + // tests/bootstrap.php + // ... + + // 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')); + + // enable auto-refreshing "globally" + Zenstruck\Foundry\Test\TestState::enableDefaultProxyAutoRefresh(); + + // disable auto-refreshing "globally" + Zenstruck\Foundry\Test\TestState::disableDefaultProxyAutoRefresh(); + +.. note:: + + If using `bundle configuration`_ as well, *test-only configuration* will override the + bundle configuration. + +Using without the Bundle +~~~~~~~~~~~~~~~~~~~~~~~~ + +The provided bundle is not strictly required to use Foundry for tests. You can have all your factories, stories, and +configuration live in your ``tests/`` directory. You can configure foundry with +`Test-Only Configuration`_. + +.. _stories: + +Stories +------- + +Stories are useful if you find your test's *arrange* step is getting complex (loading lots of fixtures) or duplicating +logic between tests and/or your dev fixtures. They are used to extract a specific database *state* into a *story*. +Stories can be loaded in your fixtures and in your tests, they can also depend on other stories. + +Create a story using the maker command: + +.. code-block:: terminal + + $ bin/console make:story Post + +.. 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: + +.. code-block:: php + + // src/Story/PostStory.php + + namespace App\Story; + + use App\Factory\CategoryFactory; + use App\Factory\PostFactory; + use App\Factory\TagFactory; + use Zenstruck\Foundry\Story; + + final class PostStory extends Story + { + public function build(): void + { + // create 10 Category's + CategoryFactory::createMany(10); + + // create 20 Tag's + TagFactory::createMany(20); + + // create 50 Post's + PostFactory::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), + ]; + }); + } + } + +Use the new story in your tests, dev fixtures, or even other stories: + +.. code-block:: php + + PostStory::load(); // loads the state defined in PostStory::build() + + PostStory::load(); // does nothing - already loaded + +.. note:: + + Objects persisted in stories are cleared after each test (unless it is a + :ref:`Global State Story `). + +Stories as Services +~~~~~~~~~~~~~~~~~~~ + +If your stories require dependencies, you can define them as a service: + +.. code-block:: 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. If not, register the service and tag +with ``foundry.story``. + +.. note:: + + The provided bundle is required for stories as services. + +Story State +~~~~~~~~~~~ + +Another feature of *stories* is the ability for them to *remember* the objects they created to be referenced later: + +.. code-block:: php + + // src/Story/CategoryStory.php + + namespace App\Story; + + use App\Factory\CategoryFactory; + use Zenstruck\Foundry\Story; + + final class CategoryStory extends Story + { + public function build(): void + { + $this->add('php', CategoryFactory::createOne(['name' => 'php'])); + + // factories are created when added as state + $this->add('symfony', CategoryFactory::new(['name' => 'symfony'])); + } + } + +Later, you can access the story's state when creating other fixtures: + +.. code-block:: php + + PostFactory::createOne(['category' => CategoryStory::load()->get('php')]); + + // or use the magic method (functionally equivalent to above) + PostFactory::createOne(['category' => CategoryStory::php()]); + +.. note:: + + Story state is cleared after each test (unless it is a :ref:`Global State Story `). + +Bundle Configuration +-------------------- + +Since the bundle is intended to be used in your *dev* and *test* environments, you'll want the configuration +for each environment to match. The easiest way to do this is have your *test* config, import *dev*. This +way, there is just one place to set your config. + +.. configuration-block:: + + .. code-block:: yaml + + # config/packages/dev/zenstruck_foundry.yaml + + zenstruck_foundry: + # ... + + # config/packages/test/zenstruck_foundry.yaml + + # just import the dev config + imports: + - { resource: ../dev/zenstruck_foundry.yaml } + +Full Default Bundle Configuration +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. configuration-block:: + + .. code-block:: yaml + + zenstruck_foundry: + + # Whether to auto-refresh proxies by default (https://github.com/zenstruck/foundry#auto-refresh) + auto_refresh_proxies: true + + # 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: false + + # Whether or not to allow extra attributes. + allow_extra_attributes: false + + # Whether or not to skip setters and force set object properties (public/private/protected) directly. + always_force_properties: false + + # Customize the instantiator service. + service: null # Example: my_instantiator + + .. code-block:: php + + $config->extension('zenstruck_foundry', [ + + // Whether to auto-refresh proxies by default (https://github.com/zenstruck/foundry#auto-refresh) + 'auto_refresh_proxies' => false, + + // Configure faker to be used by your factories. + 'faker' => [ + + // Change the default faker locale. + 'locale' => null, + + // Customize the faker service. + 'service' => null + ], + + // Configure the default instantiator used by your factories. + 'instantiator' => [ + + // Whether or not to call an object's constructor during instantiation. + 'without_constructor' => false, + + // Whether or not to allow extra attributes. + 'allow_extra_attributes' => false, + + // Whether or not to skip setters and force set object properties (public/private/protected) directly. + 'always_force_properties' => false, + + // Customize the instantiator service. + 'service' => null + ] + ]); diff --git a/src/Bundle/DependencyInjection/Configuration.php b/src/Bundle/DependencyInjection/Configuration.php index 6186b6bef..d6359caab 100644 --- a/src/Bundle/DependencyInjection/Configuration.php +++ b/src/Bundle/DependencyInjection/Configuration.php @@ -17,7 +17,7 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder->getRootNode() ->children() ->booleanNode('auto_refresh_proxies') - ->info('Whether to auto-refresh proxies by default (https://github.com/zenstruck/foundry#auto-refresh)') + ->info('Whether to auto-refresh proxies by default (https://symfony.com/bundles/foundry/current/index.html#auto-refresh)') ->defaultNull() ->end() ->arrayNode('faker') diff --git a/src/Bundle/Maker/MakeFactory.php b/src/Bundle/Maker/MakeFactory.php index 5a4ee2b43..4788e20e6 100644 --- a/src/Bundle/Maker/MakeFactory.php +++ b/src/Bundle/Maker/MakeFactory.php @@ -155,7 +155,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $io->text([ 'Next: Open your new factory and set default values/states.', - 'Find the documentation at https://github.com/zenstruck/foundry#model-factories', + 'Find the documentation at https://symfony.com/bundles/foundry/current/index.html#model-factories', ]); } diff --git a/src/Bundle/Maker/MakeStory.php b/src/Bundle/Maker/MakeStory.php index 520b9527c..099d9f6db 100644 --- a/src/Bundle/Maker/MakeStory.php +++ b/src/Bundle/Maker/MakeStory.php @@ -75,7 +75,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $io->text([ 'Next: Open your story class and start customizing it.', - 'Find the documentation at https://github.com/zenstruck/foundry#stories', + 'Find the documentation at https://symfony.com/bundles/foundry/current/index.html#stories', ]); } diff --git a/src/Bundle/Resources/skeleton/Factory.tpl.php b/src/Bundle/Resources/skeleton/Factory.tpl.php index 80dda0ef7..7c09d955c 100644 --- a/src/Bundle/Resources/skeleton/Factory.tpl.php +++ b/src/Bundle/Resources/skeleton/Factory.tpl.php @@ -34,13 +34,13 @@ public function __construct() { parent::__construct(); - // TODO inject services if required (https://github.com/zenstruck/foundry#factories-as-services) + // TODO inject services if required (https://symfony.com/bundles/foundry/current/index.html#factories-as-services) } protected function getDefaults(): array { return [ - // TODO add your default values here (https://github.com/zenstruck/foundry#model-factories) + // TODO add your default values here (https://symfony.com/bundles/foundry/current/index.html#model-factories) $type) { echo " '".$fieldname."' => ".$type."\n"; @@ -51,7 +51,7 @@ protected function getDefaults(): array protected function initialize(): self { - // see https://github.com/zenstruck/foundry#initialization + // see https://symfony.com/bundles/foundry/current/index.html#initialization return $this // ->afterInstantiate(function(getShortName() ?> $getShortName()) ?>) {}) ; diff --git a/src/Bundle/Resources/skeleton/Story.tpl.php b/src/Bundle/Resources/skeleton/Story.tpl.php index c2bfc5e18..e5cc68bf6 100644 --- a/src/Bundle/Resources/skeleton/Story.tpl.php +++ b/src/Bundle/Resources/skeleton/Story.tpl.php @@ -8,6 +8,6 @@ final class extends Story { public function build(): void { - // TODO build your story here (https://github.com/zenstruck/foundry#stories) + // TODO build your story here (https://symfony.com/bundles/foundry/current/index.html#stories) } } diff --git a/src/Instantiator.php b/src/Instantiator.php index d71f149d0..698fecaa2 100644 --- a/src/Instantiator.php +++ b/src/Instantiator.php @@ -35,7 +35,7 @@ public function __invoke(array $attributes, string $class): object foreach ($attributes as $attribute => $value) { if (0 === \mb_strpos($attribute, 'optional:')) { - trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://github.com/zenstruck/foundry#instantiation).'); + trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); continue; } @@ -56,7 +56,7 @@ public function __invoke(array $attributes, string $class): object } if (0 === \mb_strpos($attribute, 'force:')) { - trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://github.com/zenstruck/foundry#instantiation).'); + trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); self::forceSet($object, \mb_substr($attribute, 6), $value); diff --git a/src/Proxy.php b/src/Proxy.php index b42e8ba33..a8a962d4f 100644 --- a/src/Proxy.php +++ b/src/Proxy.php @@ -121,7 +121,7 @@ public function object(): object $om->getUnitOfWork()->computeChangeSet($om->getClassMetadata($this->class), $this->object); if (!empty($om->getUnitOfWork()->getEntityChangeSet($this->object))) { - throw new \RuntimeException(\sprintf('Cannot auto refresh "%s" as there are unsaved changes. Be sure to call ->save() or disable auto refreshing (see https://github.com/zenstruck/foundry#auto-refresh for details).', $this->class)); + throw new \RuntimeException(\sprintf('Cannot auto refresh "%s" as there are unsaved changes. Be sure to call ->save() or disable auto refreshing (see https://symfony.com/bundles/foundry/current/index.html#auto-refresh for details).', $this->class)); } } diff --git a/tests/Functional/Bundle/Maker/MakeFactoryTest.php b/tests/Functional/Bundle/Maker/MakeFactoryTest.php index 1bad222ee..0effd4c81 100644 --- a/tests/Functional/Bundle/Maker/MakeFactoryTest.php +++ b/tests/Functional/Bundle/Maker/MakeFactoryTest.php @@ -57,20 +57,20 @@ public function __construct() { parent::__construct(); - // TODO inject services if required (https://github.com/zenstruck/foundry#factories-as-services) + // TODO inject services if required (https://symfony.com/bundles/foundry/current/index.html#factories-as-services) } protected function getDefaults(): array { return [ - // TODO add your default values here (https://github.com/zenstruck/foundry#model-factories) + // TODO add your default values here (https://symfony.com/bundles/foundry/current/index.html#model-factories) 'name' => self::faker()->text(), ]; } protected function initialize(): self { - // see https://github.com/zenstruck/foundry#initialization + // see https://symfony.com/bundles/foundry/current/index.html#initialization return \$this // ->afterInstantiate(function(Category \$category) {}) ; @@ -135,20 +135,20 @@ public function __construct() { parent::__construct(); - // TODO inject services if required (https://github.com/zenstruck/foundry#factories-as-services) + // TODO inject services if required (https://symfony.com/bundles/foundry/current/index.html#factories-as-services) } protected function getDefaults(): array { return [ - // TODO add your default values here (https://github.com/zenstruck/foundry#model-factories) + // TODO add your default values here (https://symfony.com/bundles/foundry/current/index.html#model-factories) 'name' => self::faker()->text(), ]; } protected function initialize(): self { - // see https://github.com/zenstruck/foundry#initialization + // see https://symfony.com/bundles/foundry/current/index.html#initialization return \$this // ->afterInstantiate(function(Tag \$tag) {}) ; @@ -209,20 +209,20 @@ public function __construct() { parent::__construct(); - // TODO inject services if required (https://github.com/zenstruck/foundry#factories-as-services) + // TODO inject services if required (https://symfony.com/bundles/foundry/current/index.html#factories-as-services) } protected function getDefaults(): array { return [ - // TODO add your default values here (https://github.com/zenstruck/foundry#model-factories) + // TODO add your default values here (https://symfony.com/bundles/foundry/current/index.html#model-factories) 'name' => self::faker()->text(), ]; } protected function initialize(): self { - // see https://github.com/zenstruck/foundry#initialization + // see https://symfony.com/bundles/foundry/current/index.html#initialization return \$this // ->afterInstantiate(function(Category \$category) {}) ; @@ -287,20 +287,20 @@ public function __construct() { parent::__construct(); - // TODO inject services if required (https://github.com/zenstruck/foundry#factories-as-services) + // TODO inject services if required (https://symfony.com/bundles/foundry/current/index.html#factories-as-services) } protected function getDefaults(): array { return [ - // TODO add your default values here (https://github.com/zenstruck/foundry#model-factories) + // TODO add your default values here (https://symfony.com/bundles/foundry/current/index.html#model-factories) 'name' => self::faker()->text(), ]; } protected function initialize(): self { - // see https://github.com/zenstruck/foundry#initialization + // see https://symfony.com/bundles/foundry/current/index.html#initialization return \$this // ->afterInstantiate(function(Tag \$tag) {}) ; diff --git a/tests/Functional/Bundle/Maker/MakeStoryTest.php b/tests/Functional/Bundle/Maker/MakeStoryTest.php index 141473718..62b080da1 100644 --- a/tests/Functional/Bundle/Maker/MakeStoryTest.php +++ b/tests/Functional/Bundle/Maker/MakeStoryTest.php @@ -34,7 +34,7 @@ final class FooBarStory extends Story { public function build(): void { - // TODO build your story here (https://github.com/zenstruck/foundry#stories) + // TODO build your story here (https://symfony.com/bundles/foundry/current/index.html#stories) } } @@ -70,7 +70,7 @@ final class FooBarStory extends Story { public function build(): void { - // TODO build your story here (https://github.com/zenstruck/foundry#stories) + // TODO build your story here (https://symfony.com/bundles/foundry/current/index.html#stories) } } @@ -103,7 +103,7 @@ final class FooBarStory extends Story { public function build(): void { - // TODO build your story here (https://github.com/zenstruck/foundry#stories) + // TODO build your story here (https://symfony.com/bundles/foundry/current/index.html#stories) } } @@ -139,7 +139,7 @@ final class FooBarStory extends Story { public function build(): void { - // TODO build your story here (https://github.com/zenstruck/foundry#stories) + // TODO build your story here (https://symfony.com/bundles/foundry/current/index.html#stories) } } diff --git a/tests/Unit/InstantiatorTest.php b/tests/Unit/InstantiatorTest.php index bd043709a..e91da48fe 100644 --- a/tests/Unit/InstantiatorTest.php +++ b/tests/Unit/InstantiatorTest.php @@ -167,7 +167,7 @@ public function extra_attributes_not_defined_throws_exception(): void */ public function can_prefix_extra_attribute_key_with_optional_to_avoid_exception(): void { - $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://github.com/zenstruck/foundry#instantiation).'); + $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); $object = (new Instantiator())([ 'propB' => 'B', @@ -229,7 +229,7 @@ public function can_set_attributes_that_should_be_force_set(): void */ public function prefixing_attribute_key_with_force_sets_the_property_directly(): void { - $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://github.com/zenstruck/foundry#instantiation).'); + $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); $object = (new Instantiator())([ 'propA' => 'A', @@ -251,7 +251,7 @@ public function prefixing_attribute_key_with_force_sets_the_property_directly(): */ public function prefixing_snake_case_attribute_key_with_force_sets_the_property_directly(): void { - $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://github.com/zenstruck/foundry#instantiation).'); + $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); $object = (new Instantiator())([ 'prop_a' => 'A', @@ -273,7 +273,7 @@ public function prefixing_snake_case_attribute_key_with_force_sets_the_property_ */ public function prefixing_kebab_case_attribute_key_with_force_sets_the_property_directly(): void { - $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://github.com/zenstruck/foundry#instantiation).'); + $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); $object = (new Instantiator())([ 'prop-a' => 'A', @@ -295,7 +295,7 @@ public function prefixing_kebab_case_attribute_key_with_force_sets_the_property_ */ public function prefixing_invalid_attribute_key_with_force_throws_exception(): void { - $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://github.com/zenstruck/foundry#instantiation).'); + $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Class "Zenstruck\Foundry\Tests\Unit\InstantiatorDummy" does not have property "extra".'); @@ -457,7 +457,7 @@ public function always_force_mode_throws_exception_for_extra_attributes(): void */ public function always_force_mode_allows_optional_attribute_name_prefix(): void { - $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://github.com/zenstruck/foundry#instantiation).'); + $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); $object = (new Instantiator())->alwaysForceProperties()([ 'propB' => 'B', From 39f69f95bb3602d0e77ac5cf8c4359f03c161d95 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Mon, 30 Aug 2021 08:00:44 -0400 Subject: [PATCH 12/12] [doc] update symfony.com links (#191) --- README.md | 4 ++-- docs/index.rst | 4 ++-- .../DependencyInjection/Configuration.php | 2 +- src/Bundle/Maker/MakeFactory.php | 2 +- src/Bundle/Maker/MakeStory.php | 2 +- src/Bundle/Resources/skeleton/Factory.tpl.php | 6 ++--- src/Bundle/Resources/skeleton/Story.tpl.php | 2 +- src/Instantiator.php | 4 ++-- src/Proxy.php | 2 +- .../Bundle/Maker/MakeFactoryTest.php | 24 +++++++++---------- .../Functional/Bundle/Maker/MakeStoryTest.php | 8 +++---- tests/Unit/InstantiatorTest.php | 12 +++++----- 12 files changed, 36 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index e2dc4ace9..55c9a88dd 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,12 @@ $post = PostFactory::new() // Create the factory for Post objects ; ``` -The factories can be used inside [DoctrineFixturesBundle](https://symfony.com/doc/master/bundles/DoctrineFixturesBundle/index.html) +The factories can be used inside [DoctrineFixturesBundle](https://symfony.com/bundles/DoctrineFixturesBundle/current/index.html) to load fixtures or inside your tests, [where it has even more features](#using-in-your-tests). Want to watch a screencast 🎥 about it? Check out https://symfonycasts.com/foundry -**[Read the Documentation](https://symfony.com/bundles/foundry/current/index.html)** +**[Read the Documentation](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** ## Credit diff --git a/docs/index.rst b/docs/index.rst index 1f0e875f7..1390189cd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,7 +9,7 @@ Installation $ composer require zenstruck/foundry --dev To use the ``make:*`` commands from this bundle, ensure -`Symfony MakerBundle `_ is installed. +`Symfony MakerBundle `_ is installed. *If not using Symfony Flex, be sure to enable the bundle in your **test**/**dev** environments.* @@ -913,7 +913,7 @@ Now, after creating objects using this factory, you'd have to call ``->save()`` Using with DoctrineFixturesBundle --------------------------------- -Foundry works out of the box with `DoctrineFixturesBundle `_. +Foundry works out of the box with `DoctrineFixturesBundle `_. You can simply use your factories and stories right within your fixture files: .. code-block:: php diff --git a/src/Bundle/DependencyInjection/Configuration.php b/src/Bundle/DependencyInjection/Configuration.php index d6359caab..bd50417e1 100644 --- a/src/Bundle/DependencyInjection/Configuration.php +++ b/src/Bundle/DependencyInjection/Configuration.php @@ -17,7 +17,7 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder->getRootNode() ->children() ->booleanNode('auto_refresh_proxies') - ->info('Whether to auto-refresh proxies by default (https://symfony.com/bundles/foundry/current/index.html#auto-refresh)') + ->info('Whether to auto-refresh proxies by default (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#auto-refresh)') ->defaultNull() ->end() ->arrayNode('faker') diff --git a/src/Bundle/Maker/MakeFactory.php b/src/Bundle/Maker/MakeFactory.php index 4788e20e6..4954423ec 100644 --- a/src/Bundle/Maker/MakeFactory.php +++ b/src/Bundle/Maker/MakeFactory.php @@ -155,7 +155,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $io->text([ 'Next: Open your new factory and set default values/states.', - 'Find the documentation at https://symfony.com/bundles/foundry/current/index.html#model-factories', + 'Find the documentation at https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories', ]); } diff --git a/src/Bundle/Maker/MakeStory.php b/src/Bundle/Maker/MakeStory.php index 099d9f6db..a332a635a 100644 --- a/src/Bundle/Maker/MakeStory.php +++ b/src/Bundle/Maker/MakeStory.php @@ -75,7 +75,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $io->text([ 'Next: Open your story class and start customizing it.', - 'Find the documentation at https://symfony.com/bundles/foundry/current/index.html#stories', + 'Find the documentation at https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#stories', ]); } diff --git a/src/Bundle/Resources/skeleton/Factory.tpl.php b/src/Bundle/Resources/skeleton/Factory.tpl.php index 7c09d955c..c0040e933 100644 --- a/src/Bundle/Resources/skeleton/Factory.tpl.php +++ b/src/Bundle/Resources/skeleton/Factory.tpl.php @@ -34,13 +34,13 @@ public function __construct() { parent::__construct(); - // TODO inject services if required (https://symfony.com/bundles/foundry/current/index.html#factories-as-services) + // TODO inject services if required (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services) } protected function getDefaults(): array { return [ - // TODO add your default values here (https://symfony.com/bundles/foundry/current/index.html#model-factories) + // TODO add your default values here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories) $type) { echo " '".$fieldname."' => ".$type."\n"; @@ -51,7 +51,7 @@ protected function getDefaults(): array protected function initialize(): self { - // see https://symfony.com/bundles/foundry/current/index.html#initialization + // see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization return $this // ->afterInstantiate(function(getShortName() ?> $getShortName()) ?>) {}) ; diff --git a/src/Bundle/Resources/skeleton/Story.tpl.php b/src/Bundle/Resources/skeleton/Story.tpl.php index e5cc68bf6..faccb90ab 100644 --- a/src/Bundle/Resources/skeleton/Story.tpl.php +++ b/src/Bundle/Resources/skeleton/Story.tpl.php @@ -8,6 +8,6 @@ final class extends Story { public function build(): void { - // TODO build your story here (https://symfony.com/bundles/foundry/current/index.html#stories) + // TODO build your story here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#stories) } } diff --git a/src/Instantiator.php b/src/Instantiator.php index 698fecaa2..73616b11f 100644 --- a/src/Instantiator.php +++ b/src/Instantiator.php @@ -35,7 +35,7 @@ public function __invoke(array $attributes, string $class): object foreach ($attributes as $attribute => $value) { if (0 === \mb_strpos($attribute, 'optional:')) { - trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); + trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#instantiation).'); continue; } @@ -56,7 +56,7 @@ public function __invoke(array $attributes, string $class): object } if (0 === \mb_strpos($attribute, 'force:')) { - trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); + trigger_deprecation('zenstruck\foundry', '1.5.0', 'Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#instantiation).'); self::forceSet($object, \mb_substr($attribute, 6), $value); diff --git a/src/Proxy.php b/src/Proxy.php index a8a962d4f..fe60dea65 100644 --- a/src/Proxy.php +++ b/src/Proxy.php @@ -121,7 +121,7 @@ public function object(): object $om->getUnitOfWork()->computeChangeSet($om->getClassMetadata($this->class), $this->object); if (!empty($om->getUnitOfWork()->getEntityChangeSet($this->object))) { - throw new \RuntimeException(\sprintf('Cannot auto refresh "%s" as there are unsaved changes. Be sure to call ->save() or disable auto refreshing (see https://symfony.com/bundles/foundry/current/index.html#auto-refresh for details).', $this->class)); + throw new \RuntimeException(\sprintf('Cannot auto refresh "%s" as there are unsaved changes. Be sure to call ->save() or disable auto refreshing (see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#auto-refresh for details).', $this->class)); } } diff --git a/tests/Functional/Bundle/Maker/MakeFactoryTest.php b/tests/Functional/Bundle/Maker/MakeFactoryTest.php index 0effd4c81..b5e3dd41e 100644 --- a/tests/Functional/Bundle/Maker/MakeFactoryTest.php +++ b/tests/Functional/Bundle/Maker/MakeFactoryTest.php @@ -57,20 +57,20 @@ public function __construct() { parent::__construct(); - // TODO inject services if required (https://symfony.com/bundles/foundry/current/index.html#factories-as-services) + // TODO inject services if required (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services) } protected function getDefaults(): array { return [ - // TODO add your default values here (https://symfony.com/bundles/foundry/current/index.html#model-factories) + // TODO add your default values here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories) 'name' => self::faker()->text(), ]; } protected function initialize(): self { - // see https://symfony.com/bundles/foundry/current/index.html#initialization + // see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization return \$this // ->afterInstantiate(function(Category \$category) {}) ; @@ -135,20 +135,20 @@ public function __construct() { parent::__construct(); - // TODO inject services if required (https://symfony.com/bundles/foundry/current/index.html#factories-as-services) + // TODO inject services if required (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services) } protected function getDefaults(): array { return [ - // TODO add your default values here (https://symfony.com/bundles/foundry/current/index.html#model-factories) + // TODO add your default values here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories) 'name' => self::faker()->text(), ]; } protected function initialize(): self { - // see https://symfony.com/bundles/foundry/current/index.html#initialization + // see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization return \$this // ->afterInstantiate(function(Tag \$tag) {}) ; @@ -209,20 +209,20 @@ public function __construct() { parent::__construct(); - // TODO inject services if required (https://symfony.com/bundles/foundry/current/index.html#factories-as-services) + // TODO inject services if required (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services) } protected function getDefaults(): array { return [ - // TODO add your default values here (https://symfony.com/bundles/foundry/current/index.html#model-factories) + // TODO add your default values here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories) 'name' => self::faker()->text(), ]; } protected function initialize(): self { - // see https://symfony.com/bundles/foundry/current/index.html#initialization + // see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization return \$this // ->afterInstantiate(function(Category \$category) {}) ; @@ -287,20 +287,20 @@ public function __construct() { parent::__construct(); - // TODO inject services if required (https://symfony.com/bundles/foundry/current/index.html#factories-as-services) + // TODO inject services if required (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services) } protected function getDefaults(): array { return [ - // TODO add your default values here (https://symfony.com/bundles/foundry/current/index.html#model-factories) + // TODO add your default values here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories) 'name' => self::faker()->text(), ]; } protected function initialize(): self { - // see https://symfony.com/bundles/foundry/current/index.html#initialization + // see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization return \$this // ->afterInstantiate(function(Tag \$tag) {}) ; diff --git a/tests/Functional/Bundle/Maker/MakeStoryTest.php b/tests/Functional/Bundle/Maker/MakeStoryTest.php index 62b080da1..980ed5c97 100644 --- a/tests/Functional/Bundle/Maker/MakeStoryTest.php +++ b/tests/Functional/Bundle/Maker/MakeStoryTest.php @@ -34,7 +34,7 @@ final class FooBarStory extends Story { public function build(): void { - // TODO build your story here (https://symfony.com/bundles/foundry/current/index.html#stories) + // TODO build your story here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#stories) } } @@ -70,7 +70,7 @@ final class FooBarStory extends Story { public function build(): void { - // TODO build your story here (https://symfony.com/bundles/foundry/current/index.html#stories) + // TODO build your story here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#stories) } } @@ -103,7 +103,7 @@ final class FooBarStory extends Story { public function build(): void { - // TODO build your story here (https://symfony.com/bundles/foundry/current/index.html#stories) + // TODO build your story here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#stories) } } @@ -139,7 +139,7 @@ final class FooBarStory extends Story { public function build(): void { - // TODO build your story here (https://symfony.com/bundles/foundry/current/index.html#stories) + // TODO build your story here (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#stories) } } diff --git a/tests/Unit/InstantiatorTest.php b/tests/Unit/InstantiatorTest.php index e91da48fe..65a475a86 100644 --- a/tests/Unit/InstantiatorTest.php +++ b/tests/Unit/InstantiatorTest.php @@ -167,7 +167,7 @@ public function extra_attributes_not_defined_throws_exception(): void */ public function can_prefix_extra_attribute_key_with_optional_to_avoid_exception(): void { - $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); + $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#instantiation).'); $object = (new Instantiator())([ 'propB' => 'B', @@ -229,7 +229,7 @@ public function can_set_attributes_that_should_be_force_set(): void */ public function prefixing_attribute_key_with_force_sets_the_property_directly(): void { - $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); + $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#instantiation).'); $object = (new Instantiator())([ 'propA' => 'A', @@ -251,7 +251,7 @@ public function prefixing_attribute_key_with_force_sets_the_property_directly(): */ public function prefixing_snake_case_attribute_key_with_force_sets_the_property_directly(): void { - $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); + $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#instantiation).'); $object = (new Instantiator())([ 'prop_a' => 'A', @@ -273,7 +273,7 @@ public function prefixing_snake_case_attribute_key_with_force_sets_the_property_ */ public function prefixing_kebab_case_attribute_key_with_force_sets_the_property_directly(): void { - $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); + $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#instantiation).'); $object = (new Instantiator())([ 'prop-a' => 'A', @@ -295,7 +295,7 @@ public function prefixing_kebab_case_attribute_key_with_force_sets_the_property_ */ public function prefixing_invalid_attribute_key_with_force_throws_exception(): void { - $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); + $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "force:" property prefixes is deprecated, use Instantiator::alwaysForceProperties() instead (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#instantiation).'); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Class "Zenstruck\Foundry\Tests\Unit\InstantiatorDummy" does not have property "extra".'); @@ -457,7 +457,7 @@ public function always_force_mode_throws_exception_for_extra_attributes(): void */ public function always_force_mode_allows_optional_attribute_name_prefix(): void { - $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://symfony.com/bundles/foundry/current/index.html#instantiation).'); + $this->expectDeprecation('Since zenstruck\foundry 1.5.0: Using "optional:" attribute prefixes is deprecated, use Instantiator::allowExtraAttributes() instead (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#instantiation).'); $object = (new Instantiator())->alwaysForceProperties()([ 'propB' => 'B',