diff --git a/composer.json b/composer.json index c706f323..3ea5b304 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,8 @@ "illuminate/notifications": "^9.0|^10.0", "illuminate/queue": "^9.0|^10.0", "phpstan/phpstan": "^1.9", - "phpstan/phpstan-deprecation-rules": "^1.1" + "phpstan/phpstan-deprecation-rules": "^1.1", + "fakerphp/faker": "^1.20" }, "autoload": { "psr-4": { @@ -61,7 +62,7 @@ }, "suggest": { "laravel-doctrine/fluent": "Fluent mapping driver (alternative to xml, yaml, ... (~1.1).", - "fzaninotto/faker": "Required to use the entity factory builder (~1.4).", + "fakerphp/faker": "Required to use the entity factory builder (^1.9.1).", "laravel-doctrine/acl": "to integrate Doctrine roles & permissions with Laravel's Authorization system (~1.0)", "laravel-doctrine/extensions": "to add Behavioral and Query/Type Extensions for Laravel Doctrine (~1.0)", "laravel-doctrine/migrations": "to add support for migrations in Laravel Doctrine (~1.0)", diff --git a/src/Testing/Factories/Factory.php b/src/Testing/Factories/Factory.php new file mode 100644 index 00000000..38242a33 --- /dev/null +++ b/src/Testing/Factories/Factory.php @@ -0,0 +1,650 @@ + + */ + protected $class; + + /** + * The number of models that should be generated. + * + * @var int|null + */ + protected $count; + + /** + * The state transformations that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $states; + + /** + * The "after making" callbacks that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $afterMaking; + + /** + * The "after creating" callbacks that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $afterCreating; + + /** + * The current Faker instance. + * + * @var \Faker\Generator + */ + protected $faker; + + /** + * @var ManagerRegistry + */ + protected ManagerRegistry $registry; + + /** + * The default namespace where factories reside. + * + * @var string + */ + protected static $namespace = 'Database\\Factories\\'; + + /** + * The default model name resolver. + * + * @var callable + */ + protected static $modelNameResolver; + + /** + * The factory name resolver. + * + * @var callable + */ + protected static $factoryNameResolver; + + /** + * Create a new factory instance. + * + * @param int|null $count + * @param \Illuminate\Support\Collection|null $states + * @param \Illuminate\Support\Collection|null $afterMaking + * @param \Illuminate\Support\Collection|null $afterCreating + * @return void + */ + public function __construct( + ?int $count = null, + ?Collection $states = null, + ?Collection $afterMaking = null, + ?Collection $afterCreating = null + ) { + $this->count = $count; + $this->states = $states ?? new Collection; + $this->afterMaking = $afterMaking ?? new Collection; + $this->afterCreating = $afterCreating ?? new Collection; + $this->faker = $this->withFaker(); + $this->registry = Container::getInstance()->make(ManagerRegistry::class); + } + + /** + * Define the model's default state. + * + * @return array + */ + abstract public function definition(); + + /** + * Get a new factory instance for the given attributes. + * + * @param (callable(array): array)|array $attributes + * @return static + */ + public static function new($attributes = []) + { + return (new static)->state($attributes)->configure(); + } + + /** + * Get a new factory instance for the given number of models. + * + * @param int $count + * @return static + */ + public static function times(int $count) + { + return static::new()->count($count); + } + + /** + * Configure the factory. + * + * @return $this + */ + public function configure() + { + return $this; + } + + /** + * Get the raw attributes generated by the factory. + * + * @param (callable(array): array)|array $attributes + * @param object|null $parent + * @return array + */ + public function raw($attributes = [], ?object $parent = null) + { + if ($this->count === null) { + return $this->state($attributes)->getExpandedAttributes($parent); + } + + return array_map(function () use ($attributes, $parent) { + return $this->state($attributes)->getExpandedAttributes($parent); + }, range(1, $this->count)); + } + + /** + * Create a single model and persist it to the database. + * + * @param (callable(array): array)|array $attributes + * @return TModel + */ + public function createOne($attributes = []) + { + return $this->count(null)->create($attributes); + } + + /** + * Create a collection of models and persist them to the database. + * + * @param iterable> $records + * @return array + */ + public function createMany(iterable $records): array + { + $result = []; + foreach ($records as $record){ + $result[] = $this->state($record)->create(); + } + return $result; + } + + /** + * Create a collection of models and persist them to the database. + * + * @param (callable(array): array)|array $attributes + * @param object|null $parent + * @return TModel|array + */ + public function create($attributes = [], ?object $parent = null) + { + if (!empty($attributes)) { + return $this->state($attributes)->create([], $parent); + } + + $results = $this->make($attributes, $parent); + + if (is_array($results)) { + $this->store($results); + $this->callAfterCreating($results, $parent); + } else { + $this->store([$results]); + $this->callAfterCreating([$results], $parent); + } + + return $results; + } + + /** + * Create a callback that persists a model in the database when invoked. + * + * @param array $attributes + * @param object|null $parent + * @return \Closure(): (TModel|array) + */ + public function lazy(array $attributes = [], ?object $parent = null) + { + return fn() => $this->create($attributes, $parent); + } + + /** + * Stores results in ORM. + * + * @param array $results + * @return void + */ + protected function store(array $results): void + { + $manager = $this->registry->getManagerForClass($this->className()); + foreach ($results as $result){ + $manager->persist($result); + } + $manager->flush(); + } + + /** + * Make a single instance of the model. + * + * @param (callable(array): array)|array $attributes + * @return TModel + */ + public function makeOne($attributes = []): object + { + return $this->count(null)->make($attributes); + } + + /** + * Create a collection of models. + * + * @param (callable(array): array)|array $attributes + * @param object|null $parent + * @return TModel|array + */ + public function make($attributes = [], ?object $parent = null) + { + if (!empty($attributes)) { + return $this->state($attributes)->make([], $parent); + } + + if ($this->count === null) { + return tap($this->makeInstance($parent), function ($instance) { + $this->callAfterMaking([$instance]); + }); + } + + if ($this->count < 1) { + return []; + } + + $instances = array_map(function () use ($parent) { + return $this->makeInstance($parent); + }, range(1, $this->count)); + + $this->callAfterMaking($instances); + + return $instances; + } + + /** + * Make an instance of the model with the given attributes. + * + * @param object|null $parent + * @return TModel + */ + protected function makeInstance(?object $parent): object + { + $attributes = $this->getExpandedAttributes($parent); + + /** @var ClassMetadata $metadata */ + $metadata = $this->registry + ->getManagerForClass($this->className()) + ->getClassMetadata($this->className()); + + $toManyRelations = (new Collection($metadata->getAssociationMappings())) + ->keys() + ->filter(function ($association) use ($metadata) { + return $metadata->isCollectionValuedAssociation($association); + }) + ->mapWithKeys(function ($association) { + return [$association => new ArrayCollection]; + }); + + return SimpleHydrator::hydrate( + $this->className(), + array_merge($toManyRelations->all(), $attributes) + ); + } + + /** + * Get a raw attributes array for the model. + * + * @param object|null $parent + * @return array + */ + protected function getExpandedAttributes(?object $parent): array + { + return $this->expandAttributes($this->getRawAttributes($parent)); + } + + /** + * Get the raw attributes for the model as an array. + * + * @param object|null $parent + * @return array + */ + protected function getRawAttributes(?object $parent): array + { + return $this->states->reduce(function ($carry, $state) use ($parent) { + if ($state instanceof Closure) { + $state = $state->bindTo($this); + } + + return array_merge($carry, $state($carry, $parent)); + }, $this->definition()); + } + + /** + * Expand all attributes to their underlying values. + * + * @param array $definition + * @return array + */ + protected function expandAttributes(array $definition): array + { + return collect($definition) + ->map($evaluateRelations = function ($attribute) { + if (is_array($attribute) || $attribute instanceof \Traversable) { + foreach ($attribute as $e) { + if (is_object($e)) { + $this->registry + ->getManagerForClass(get_class($e)) + ->persist($e); + } + } + } elseif (is_object($attribute) && !($attribute instanceof \Closure)) { + $this->registry + ->getManagerForClass(get_class($attribute)) + ->persist($attribute); + } + + return $attribute; + }) + ->map(function ($attribute, $key) use (&$definition, $evaluateRelations) { + if (is_callable($attribute) && !is_string($attribute) && !is_array($attribute)) { + $attribute = $attribute($definition); + } + + $attribute = $evaluateRelations($attribute); + + $definition[$key] = $attribute; + + return $attribute; + }) + ->all(); + } + + /** + * Add a new state transformation to the model definition. + * + * @param (callable(array, TModel|null=): array)|array $state + * @return static + */ + public function state($state): static + { + return $this->newInstance([ + 'states' => $this->states->concat([ + is_callable($state) ? $state : function () use ($state) { + return $state; + }, + ]), + ]); + } + + /** + * Set a single model attribute. + * + * @param string|int $key + * @param mixed $value + * @return static + */ + public function set($key, $value): static + { + return $this->state([$key => $value]); + } + + /** + * Add a new sequenced state transformation to the model definition. + * + * @param array $sequence + * @return static + */ + public function sequence(...$sequence): static + { + return $this->state(new Sequence(...$sequence)); + } + + /** + * Add a new sequenced state transformation to the model definition and update the pending creation count to the size of the sequence. + * + * @param array $sequence + * @return static + */ + public function forEachSequence(...$sequence): static + { + return $this->state(new Sequence(...$sequence))->count(count($sequence)); + } + + /** + * Add a new cross joined sequenced state transformation to the model definition. + * + * @param array $sequence + * @return static + */ + public function crossJoinSequence(...$sequence): static + { + return $this->state(new CrossJoinSequence(...$sequence)); + } + + /** + * Add a new "after making" callback to the model definition. + * + * @param \Closure(TModel): mixed $callback + * @return static + */ + public function afterMaking(Closure $callback): static + { + return $this->newInstance(['afterMaking' => $this->afterMaking->concat([$callback])]); + } + + /** + * Add a new "after creating" callback to the model definition. + * + * @param \Closure(TModel): mixed $callback + * @return static + */ + public function afterCreating(Closure $callback): static + { + return $this->newInstance(['afterCreating' => $this->afterCreating->concat([$callback])]); + } + + /** + * Call the "after making" callbacks for the given model instances. + * + * @param array $instances + * @return void + */ + protected function callAfterMaking(array $instances): void + { + foreach ($instances as $instance) { + $this->afterMaking->each(function ($callback) use ($instance) { + $callback($instance); + }); + } + } + + /** + * Call the "after creating" callbacks for the given model instances. + * + * @param array $instances + * @param object|null $parent + * @return void + */ + protected function callAfterCreating(array $instances, ?object $parent = null): void + { + foreach ($instances as $instance){ + $this->afterCreating->each(function ($callback) use ($instance, $parent) { + $callback($instance, $parent); + }); + } + } + + /** + * Specify how many models should be generated. + * + * @param int|null $count + * @return static + */ + public function count(?int $count): static + { + return $this->newInstance(['count' => $count]); + } + + /** + * Create a new instance of the factory builder with the given mutated properties. + * + * @param array $arguments + * @return static + */ + protected function newInstance(array $arguments = []): static + { + return new static(...array_values(array_merge([ + 'count' => $this->count, + 'states' => $this->states, + 'afterMaking' => $this->afterMaking, + 'afterCreating' => $this->afterCreating, + ], $arguments))); + } + + /** + * Get the name of the class that is generated by the factory. + * + * @return class-string + */ + public function className(): string + { + $resolver = static::$modelNameResolver ?? function (self $factory) { + $namespacedFactoryBasename = Str::replaceLast( + 'Factory', '', Str::replaceFirst(static::$namespace, '', get_class($factory)) + ); + + $factoryBasename = Str::replaceLast('Factory', '', class_basename($factory)); + + $appNamespace = static::appNamespace(); + + return class_exists($appNamespace . 'Entities\\' . $namespacedFactoryBasename) + ? $appNamespace . 'Entities\\' . $namespacedFactoryBasename + : $appNamespace . $factoryBasename; + }; + + return $this->class ?? $resolver($this); + } + + /** + * Specify the callback that should be invoked to guess model names based on factory names. + * + * @param callable(self): class-string $callback + * @return void + */ + public static function guessModelNamesUsing(callable $callback): void + { + static::$modelNameResolver = $callback; + } + + /** + * Specify the default namespace that contains the application's model factories. + * + * @param string $namespace + * @return void + */ + public static function useNamespace(string $namespace): void + { + static::$namespace = $namespace; + } + + /** + * Get a new factory instance for the given model name. + * + * @param class-string $modelName + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + public static function factoryForModel(string $modelName) + { + $factory = static::resolveFactoryName($modelName); + + return $factory::new(); + } + + /** + * Specify the callback that should be invoked to guess factory names based on dynamic relationship names. + * + * @param callable(class-string<\Illuminate\Database\Eloquent\Model>): class-string<\Illuminate\Database\Eloquent\Factories\Factory> $callback + * @return void + */ + public static function guessFactoryNamesUsing(callable $callback) + { + static::$factoryNameResolver = $callback; + } + + /** + * Get a new Faker instance. + * + * @return \Faker\Generator + */ + protected function withFaker() + { + return Container::getInstance()->make(Generator::class); + } + + /** + * Get the factory name for the given model name. + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $modelName + * @return class-string<\Illuminate\Database\Eloquent\Factories\Factory> + */ + public static function resolveFactoryName(string $modelName) + { + $resolver = static::$factoryNameResolver ?? function (string $modelName) { + $appNamespace = static::appNamespace(); + + $modelName = Str::startsWith($modelName, $appNamespace . 'Entities\\') + ? Str::after($modelName, $appNamespace . 'Entities\\') + : Str::after($modelName, $appNamespace); + + return static::$namespace . $modelName . 'Factory'; + }; + + return $resolver($modelName); + } + + /** + * Get the application namespace for the application. + * + * @return string + */ + protected static function appNamespace() + { + try { + return Container::getInstance() + ->make(Application::class) + ->getNamespace(); + } catch (Throwable $e) { + return 'App\\'; + } + } +} diff --git a/src/Testing/Factories/HasEntityFactory.php b/src/Testing/Factories/HasEntityFactory.php new file mode 100644 index 00000000..28dc97ba --- /dev/null +++ b/src/Testing/Factories/HasEntityFactory.php @@ -0,0 +1,32 @@ + + */ + public static function factory($count = null, $state = []) + { + $factory = static::newFactory() ?: Factory::factoryForModel(get_called_class()); + + return $factory + ->count(is_numeric($count) ? $count : null) + ->state(is_callable($count) || is_array($count) ? $count : $state); + } + + /** + * Create a new factory instance for the model. + * + * @return \LaravelDoctrine\ORM\Testing\Factories\Factory|null + */ + protected static function newFactory() + { + return null; + } +} diff --git a/tests/Stubs/Faker/Generator.php b/tests/Stubs/Faker/Generator.php deleted file mode 100644 index 436204b7..00000000 --- a/tests/Stubs/Faker/Generator.php +++ /dev/null @@ -1,7 +0,0 @@ -instance(ManagerRegistry::class, $registry); + + $container->singleton(\Faker\Generator::class, function () { + return \Faker\Factory::create('en_US'); + }); + + $this->entityManager = \Mockery::mock(EntityManagerInterface::class); + $registry + ->shouldReceive('getManagerForClass') + ->with(FactoriesEntityStub::class) + ->andReturn($this->entityManager); + + $classMetadata = $this->getEntityManager()->getClassMetadata(FactoriesEntityStub::class); + + $this->entityManager->shouldReceive('getClassMetadata') + ->with(FactoriesEntityStub::class) + ->andReturn($classMetadata); + + $this->entityManager->shouldReceive('persist'); + $this->entityManager->shouldReceive('flush'); + } + + protected function getEntityManager() + { + $conn = [ + 'driver' => 'pdo_sqlite', + 'database' => ':memory:', + ]; + + $config = Setup::createAnnotationMetadataConfiguration([__DIR__], true); + + return EntityManager::create($conn, $config); + } + + public function test_it_makes_instances_of_the_class() + { + $instance = FactoriesEntityStub::factory()->make(); + + $this->assertInstanceOf(FactoriesEntityStub::class, $instance); + $this->assertNotNull($instance->id); + $this->assertNotNull($instance->name); + } + + public function test_it_creates_instances_of_the_class() + { + $instance = FactoriesEntityStub::factory()->create(); + + $this->entityManager->shouldHaveReceived('persist')->with($instance)->once(); + $this->entityManager->shouldHaveReceived('flush')->once(); + } + + public function test_it_fills_to_many_relations_with_array_collections() + { + $instance = FactoriesEntityStub::factory()->make(); + + $this->assertInstanceOf(ArrayCollection::class, $instance->others); + } + + public function test_it_shouldnt_override_predefined_relations() + { + $instance = FactoriesEntityStub::factory()->state(['others' => ['Foo']])->make(); + + $this->assertEquals(['Foo'], $instance->others); + } + + public function test_it_should_execute_closures() + { + $instance = FactoriesEntityStub::factory()->state([ + 'id' => function() { return 42; }, + 'name' => function() { return 'Foo'; }, + ])->make(); + + $this->assertEquals(42, $instance->id); + $this->assertEquals('Foo', $instance->name); + } + + public function test_it_should_persist_entities_returned_by_a_closure() + { + $newStub = new FactoriesEntityStub(); + FactoriesEntityStub::factory()->state([ + 'others' => function() use ($newStub) { return [$newStub]; }, + ])->create(); + + $this->entityManager->shouldHaveReceived('persist')->with($newStub)->once(); + } + + public function test_it_handles_states() + { + $instance = FactoriesEntityStub::factory()->suspended()->make(); + + $this->assertEquals('name suspended', $instance->name); + $this->assertLessThan(0, $instance->id); + } + + public function test_it_handles_after_making_callback() + { + $instance = FactoriesEntityStub::factory()->make(); + + $this->assertCount(1, FactoriesEntityStubFactory::$afterMakingInstances); + $this->assertCount(0, FactoriesEntityStubFactory::$afterCreatingInstances); + $this->assertEquals($instance, FactoriesEntityStubFactory::$afterMakingInstances[0]); + } + + public function test_it_handles_after_making_callback_with_multiple_models() + { + $instances = FactoriesEntityStub::factory(3)->make(); + + $this->assertCount(3, FactoriesEntityStubFactory::$afterMakingInstances); + $this->assertCount(0, FactoriesEntityStubFactory::$afterCreatingInstances); + $this->assertEquals($instances, FactoriesEntityStubFactory::$afterMakingInstances); + } + + public function test_it_handles_after_creating_callback() + { + $instance = FactoriesEntityStub::factory()->create(); + + $this->assertCount(1, FactoriesEntityStubFactory::$afterMakingInstances); + $this->assertCount(1, FactoriesEntityStubFactory::$afterCreatingInstances); + $this->assertEquals($instance, FactoriesEntityStubFactory::$afterCreatingInstances[0]); + } + + public function test_it_handles_after_creating_callback_with_multiple_models() + { + $instances = FactoriesEntityStub::factory(3)->create(); + + $this->assertCount(3, FactoriesEntityStubFactory::$afterMakingInstances); + $this->assertCount(3, FactoriesEntityStubFactory::$afterCreatingInstances); + $this->assertEquals($instances, FactoriesEntityStubFactory::$afterCreatingInstances); + } + + public function test_sequences() + { + $instances = FactoriesEntityStub::factory(2)->sequence( + ['name' => 'Taylor Otwell'], + ['name' => 'Abigail Otwell'], + )->create(); + + $this->assertSame('Taylor Otwell', $instances[0]->name); + $this->assertSame('Abigail Otwell', $instances[1]->name); + + $instances = FactoriesEntityStub::factory()->times(2)->sequence(function ($sequence) { + return ['name' => 'index: '.$sequence->index]; + })->create(); + + $this->assertSame('index: 0', $instances[0]->name); + $this->assertSame('index: 1', $instances[1]->name); + } + + public function test_counted_sequence() + { + $factory = FactoriesEntityStub::factory()->forEachSequence( + ['name' => 'Taylor Otwell'], + ['name' => 'Abigail Otwell'], + ['name' => 'Dayle Rees'] + ); + + $class = new ReflectionClass($factory); + $prop = $class->getProperty('count'); + $prop->setAccessible(true); + $value = $prop->getValue($factory); + + $this->assertSame(3, $value); + } + + public function test_cross_join_sequences() + { + $assert = function ($users) { + $assertions = [ + ['name' => 'Thomas', 'lastName' => 'Anderson'], + ['name' => 'Thomas', 'lastName' => 'Smith'], + ['name' => 'Agent', 'lastName' => 'Anderson'], + ['name' => 'Agent', 'lastName' => 'Smith'], + ]; + + foreach ($assertions as $key => $assertion) { + $this->assertSame( + $assertion, + [ + 'name' => $users[$key]->name, + 'lastName' => $users[$key]->lastName + ], + ); + } + }; + + $usersByClass = FactoriesEntityStub::factory(4) + ->state( + new CrossJoinSequence( + [['name' => 'Thomas'], ['name' => 'Agent']], + [['lastName' => 'Anderson'], ['lastName' => 'Smith']], + ), + ) + ->make(); + + $assert($usersByClass); + + $usersByMethod = FactoriesEntityStub::factory(4) + ->crossJoinSequence( + [['name' => 'Thomas'], ['name' => 'Agent']], + [['lastName' => 'Anderson'], ['lastName' => 'Smith']], + ) + ->make(); + + $assert($usersByMethod); + } +} + +/** + * @Entity + */ +class FactoriesEntityStub +{ + use HasEntityFactory; + + protected static function newFactory() + { + return FactoriesEntityStubFactory::new(); + } + + /** + * @Id @GeneratedValue @Column(type="integer") + */ + public $id; + + /** + * @Column(type="string") + */ + public $name; + + /** + * @Column(type="string") + */ + public $lastName; + + /** + * @ManyToMany(targetEntity="EntityStub") + * @JoinTable(name="stub_stubs", + * joinColumns={@JoinColumn(name="owner_id", referencedColumnName="id")}, + * inverseJoinColumns={@JoinColumn(name="owned_id", referencedColumnName="id")} + * ) + */ + public $others; +} + +class FactoriesEntityStubFactory extends Factory +{ + protected $class = FactoriesEntityStub::class; + + public static $afterMakingInstances = []; + public static $afterCreatingInstances = []; + + public function configure() + { + self::$afterMakingInstances = []; + self::$afterCreatingInstances = []; + + return $this->afterMaking(function (FactoriesEntityStub $stub){ + self::$afterMakingInstances[] = $stub; + })->afterCreating(function (FactoriesEntityStub $stub){ + self::$afterCreatingInstances[] = $stub; + }); + } + + /** + * @return static + */ + public function suspended() + { + return $this->state(function (array $attributes) { + return [ + 'id' => -1 * $attributes['id'], + 'name' => "{$attributes['name']} suspended", + ]; + }); + } + + public function definition() + { + return [ + 'id' => $this->faker->unique()->randomNumber(), + 'name' => 'name', + 'lastName' => 'lastName', + ]; + } +}