diff --git a/CHANGELOG.md b/CHANGELOG.md index 0226d3a..062aeb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,25 @@ # Changelog -All notable changes to `database-seeder` will be documented in this file. +## 2.0.0 - Unreleased +- **High Impact Changes** + - Dropped support for Spiral Framework 2.x + - Method `create` in `Spiral\DatabaseSeeder\Factory\FactoryInterface` interface and + `Spiral\DatabaseSeeder\Factory\AbstractFactory` class renamed to `make` + - Method `createOne` in `Spiral\DatabaseSeeder\Factory\FactoryInterface` interface and + `Spiral\DatabaseSeeder\Factory\AbstractFactory` class renamed to `makeOne` + +- **Medium Impact Changes** + - Min PHP version increased to 8.1 + +- **Other Features** + - Added `Spiral\DatabaseSeeder\TestCase` class, which can use as a base class in tests that work with the database. + It adds the ability to use traits in test classes that add functionality for testing database applications. + To use this class, you must use the `spiral/testing` package + - Added `Spiral\DatabaseSeeder\Database\Traits\RefreshDatabase` trait. + This trait automatically creates the database structure the first time any test is run, and refresh the database before each test is run. + - Added `Spiral\DatabaseSeeder\Database\Traits\DatabaseMigrations` trait. + This trait creates a database structure using migrations before each test execution. After each test runs, + it refreshes the database and rollback migrations. ## 1.0.0 - 202X-XX-XX diff --git a/README.md b/README.md index 172c324..edd36f5 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ The package provides the ability to seed your database with data using seed clas Make sure that your server is configured with following PHP version and extensions: -- PHP 8.0+ -- Spiral framework 2.9+ +- PHP 8.1+ +- Spiral framework ^3.0 ## Installation @@ -30,8 +30,8 @@ protected const LOAD = [ \Spiral\DatabaseSeeder\Bootloader\DatabaseSeederBootloader::class, ]; ``` - -> Note: if you are using [`spiral-packages/discoverer`](https://github.com/spiral-packages/discoverer), +> **Note** +> if you are using [`spiral-packages/discoverer`](https://github.com/spiral-packages/discoverer), > you don't need to register bootloader by yourself. ## Usage @@ -68,14 +68,48 @@ class UserFactory extends AbstractFactory 'firstName' => $this->faker->firstName(), 'lastName' => $this->faker->lastName(), 'birthday' => \DateTimeImmutable::createFromMutable($this->faker->dateTime()), - 'comments' => CommentFactory::new()->times(3)->create(), // Can use other factories. + 'comments' => CommentFactory::new()->times(3)->make(), // Can use other factories. // Be careful, circular dependencies are not allowed! ]; } } ``` +A factory can be created using the method `new`. +```php +$factory = UserFactory::new(); +``` +A factory has several useful methods: +- `create` - Creates an array of entities, stores them in the database, and returns them for further use in code. +- `createOne` - Creates one entity, stores it in the database, and returns it for further use in code. +- `make` - Creates an array of entities and returns them for further use in code. +- `makeOne` - Creates one entity and returns it for further use in code. +- `raw` - or `data` property. Used to get an array of entity data (raw data, without entity creation). + +A few examples: +```php +// 10 users stored in the database +$users = UserFactory::new()->times(10)->create(); + +// one user stored in the database +$user = UserFactory::new()->createOne(); + +// 10 users. Entities will not be saved to the database. Only returned for future use +$users = UserFactory::new()->times(10)->make(); + +// one user. Will not be saved to the database +$user = UserFactory::new()->makeOne(); + +// array with raw user data +$data = UserFactory::new()->raw(); +// or +$data = UserFactory::new()->data; +``` + +### Seeding +The package provides the ability to seed the database with test data. To do this, create a Seeder class and extend it +from the `Spiral\DatabaseSeeder\Seeder\AbstractSeeder` class. Implement the `run` method. +This method should return a generator with entities to store in the database. -After that, you can use this factory in your code. For example: ```php times(100)->create() as $user) { + foreach (UserFactory::new()->times(100)->make() as $user) { yield $user; } } } ``` +## Console commands +The package provides console commands to quickly create a factory, seeder, and perform seeding of a test database +using seeder classes. +- The `Spiral\DatabaseSeeder\Console\Command\FactoryCommand` console command is used to create a factory. + The name of the factory is passed as an argument. +```bash +php ./app.php create:factory UserFactory +``` +- The `Spiral\DatabaseSeeder\Console\Command\SeederCommand` console command is used to create a seeder. + The name of the seeder is passed as an argument. +```bash +php ./app.php create:seeder UserSeeder +``` +- The `Spiral\DatabaseSeeder\Console\Command\SeedCommand` console command is used to perform seeding of a test database + using seeder classes. +```bash +php ./app.php db:seed +``` + +## Testing applications with database +The package provides several additional features for easier testing of applications with databases. + +> **Note** +Important! Be sure to set up a test database in the test application. Never use a production database for testing! + +To use these features, your application's tests must be written using the `spiral/testing` package. + +First of all, inherit the base test class that is used in tests using the new functionality. +This will make it possible to use traits to simplify working with the database in tests and provide additional methods for testing. +Example: +```php +getContainer()->get(Database::class)->hasTable($table), + \sprintf('Table [%s] does not exist.', $table) + ); + } + + /** @psalm-param non-empty-string $table */ + public function assertTableIsNotExists(string $table): void + { + static::assertFalse( + $this->getContainer()->get(Database::class)->hasTable($table), + \sprintf('Table [%s] exists.', $table) + ); + } + + /** @psalm-param non-empty-string $table */ + public function assertTableCount(string $table, int $count): void + { + $actual = $this->getContainer()->get(Database::class)->table($table)->count(); + + static::assertSame( + $count, + $actual, + \sprintf('Expected %s records in the table [%s], actual are %s.', $count, $table, $actual) + ); + } + + /** @psalm-param non-empty-string $table */ + public function assertTableHas(string $table, array $where = []): void + { + $select = $this->getContainer()->get(Database::class)->table($table)->select(); + + if ($where !== []) { + $select->where($where); + } + + static::assertTrue($select->count() >= 0, \sprintf('Record not found in the table [%s].', $table)); + } + + /** @param class-string $entity */ + public function assertEntitiesCount(string $entity, int $count): void + { + /** @var Repository $repository */ + $repository = $this->getContainer()->get(ORM::class)->getRepository($entity); + $actual = $repository->select()->count(); + + static::assertSame( + $count, + $actual, + \sprintf('Expected %s entities in the table, actual are %s.', $count, $actual) + ); + } + + /** @param class-string $entity */ + public function assertTableHasEntity(string $entity, array $where = []): void + { + /** @var Repository $repository */ + $repository = $this->getContainer()->get(ORM::class)->getRepository($entity); + $select = $repository->select(); + + if ($where !== []) { + $select->where($where); + } + + static::assertTrue($select->count() >= 0, \sprintf('Entity [%s] not found.', $entity)); + } +} diff --git a/src/Database/Traits/DatabaseMigrations.php b/src/Database/Traits/DatabaseMigrations.php index 9581661..19c66d4 100644 --- a/src/Database/Traits/DatabaseMigrations.php +++ b/src/Database/Traits/DatabaseMigrations.php @@ -14,6 +14,38 @@ trait DatabaseMigrations * Migrate the database before and after each test. */ public function runDatabaseMigrations(): void + { + $this->runCommand('cycle:migrate', ['--run' => true]); + + /* TODO wait resolving bug with finalizers + $directory = $this->getMigrationsDirectory(); + $self = $this; + $this->getContainer()->get(FinalizerInterface::class)->addFinalizer(static function () use($self, $directory) { + $self->runCommand('migrate:rollback', ['--all' => true]); + + $self->cleanupDirectories($directory); + + DatabaseState::$migrated = false; + }); + */ + } + + public function runDatabaseRollback(): void + { + $directory = $this->getMigrationsDirectory(); + + $this->runCommand('migrate:rollback', ['--all' => true]); + + $this->cleanupDirectories($directory); + + DatabaseState::$migrated = false; + } + + /** + * @invisible + * @internal + */ + private function getMigrationsDirectory(): string { $config = $this->getConfig('migration'); if (empty($config['directory'])) { @@ -28,15 +60,6 @@ public function runDatabaseMigrations(): void ); } - $this->runCommand('cycle:migrate', ['--run' => true]); - - $self = $this; - $this->getContainer()->get(FinalizerInterface::class)->addFinalizer(static function () use($self, $config) { - $self->runCommand('migrate:rollback', ['--all' => true]); - - $self->cleanupDirectories($config['directory']); - - DatabaseState::$migrated = false; - }); + return $config['directory']; } } diff --git a/src/Factory/AbstractFactory.php b/src/Factory/AbstractFactory.php index dc18ca6..338d483 100644 --- a/src/Factory/AbstractFactory.php +++ b/src/Factory/AbstractFactory.php @@ -4,12 +4,19 @@ namespace Spiral\DatabaseSeeder\Factory; +use Cycle\ORM\EntityManagerInterface; use Faker\Factory as FakerFactory; use Faker\Generator; use Laminas\Hydrator\ReflectionHydrator; use Butschster\EntityFaker\Factory; use Butschster\EntityFaker\LaminasEntityFactory; +use Spiral\Core\ContainerScope; +use Spiral\DatabaseSeeder\Factory\Exception\FactoryException; +use Spiral\DatabaseSeeder\Factory\Exception\OutsideScopeException; +/** + * @property-read $data + */ abstract class AbstractFactory implements FactoryInterface { /** @internal */ @@ -57,11 +64,13 @@ public function afterCreate(callable $afterCreate): self public function create(): array { - $entities = $this->make([$this, 'definition']); + $entities = $this->object([$this, 'definition']); if (!\is_array($entities)) { $entities = [$entities]; } + $this->storeEntities($entities); + $this->callAfterCreating($entities); return $entities; @@ -69,18 +78,80 @@ public function create(): array public function createOne(): object { - $entity = $this->make([$this, 'definition']); + $entity = $this->object([$this, 'definition']); if (\is_array($entity)) { $entity = \array_shift($entity); } + $this->storeEntities([$entity]); + $this->callAfterCreating([$entity]); return $entity; } + public function make(): array + { + $entities = $this->object([$this, 'definition']); + if (!\is_array($entities)) { + $entities = [$entities]; + } + + $this->callAfterCreating($entities); + + return $entities; + } + + public function makeOne(): object + { + $entity = $this->object([$this, 'definition']); + if (\is_array($entity)) { + $entity = \array_shift($entity); + } + + $this->callAfterCreating([$entity]); + + return $entity; + } + + public function raw(callable $definition): array + { + $this->entityFactory->define($this->entity(), $definition); + + $data = $this->entityFactory->of($this->entity())->times($this->amount)->raw($this->replaces); + + return \array_is_list($data) ? $data[0] : $data; + } + + public function __get(string $name): array + { + return match ($name) { + 'data' => $this->raw([$this, 'definition']), + default => throw new FactoryException('Undefined magic property.') + }; + } + + private function storeEntities(array $entities): void + { + $container = ContainerScope::getContainer(); + if ($container === null) { + throw new OutsideScopeException(\sprintf( + 'The container is not available. Make sure [%s] method is running in the ContainerScope.', + __METHOD__ + )); + } + + /** @var EntityManagerInterface $em */ + $em = $container->get(EntityManagerInterface::class); + + foreach ($entities as $entity) { + $em->persist($entity); + } + $em->run(); + } + /** @internal */ - private function make(callable $definition): object|array + private function object(callable $definition): object|array { $this->entityFactory->define($this->entity(), $definition); diff --git a/src/Factory/Exception/FactoryException.php b/src/Factory/Exception/FactoryException.php new file mode 100644 index 0000000..99d7460 --- /dev/null +++ b/src/Factory/Exception/FactoryException.php @@ -0,0 +1,9 @@ +run() as $entity) { - $this->entityManager->persist($entity); + if ($this->isNotExists($entity)) { + $this->entityManager->persist($entity); + } } } @@ -46,4 +52,19 @@ private function callAfterSeed(SeederInterface $seeder): void { \array_map(static fn(callable $callable) => $callable($seeder), $this->afterSeed); } + + private function isNotExists(object $entity): bool + { + $keys = $this->orm->getSchema()->define($this->orm->resolveRole($entity), SchemaInterface::PRIMARY_KEY); + + $values = []; + foreach ($keys as $key) { + $ref = new \ReflectionProperty($entity, $key); + if ($ref->isInitialized($entity)) { + $values[$key] = $ref->getValue($entity); + } + } + + return $this->orm->getRepository($entity)->findByPK(new Parameter($values)) === null; + } } diff --git a/src/TestCase.php b/src/TestCase.php index f3f4492..5f70829 100644 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -4,15 +4,32 @@ namespace Spiral\DatabaseSeeder; +use Spiral\Core\ContainerScope; +use Spiral\DatabaseSeeder\Database\Traits\DatabaseAsserts; + abstract class TestCase extends \Spiral\Testing\TestCase { + use DatabaseAsserts; + protected function setUp(): void { parent::setUp(); + // Bind container to ContainerScope + (new \ReflectionClass(ContainerScope::class))->setStaticPropertyValue('container', $this->getContainer()); + $this->setUpTraits(); } + protected function tearDown(): void + { + parent::tearDown(); + + (new \ReflectionClass(ContainerScope::class))->setStaticPropertyValue('container', null); + + $this->tearDownTraits(); + } + private function setUpTraits(): void { /** @see \Spiral\DatabaseSeeder\Database\Traits\RefreshDatabase */ @@ -25,4 +42,12 @@ private function setUpTraits(): void $this->runDatabaseMigrations(); } } + + private function tearDownTraits(): void + { + /** @see \Spiral\DatabaseSeeder\Database\Traits\DatabaseMigrations */ + if (\method_exists($this, 'runDatabaseRollback')) { + $this->runDatabaseRollback(); + } + } } diff --git a/tests/app/database/Factory/CommentFactory.php b/tests/app/database/Factory/CommentFactory.php index 06dae82..4f1a83b 100644 --- a/tests/app/database/Factory/CommentFactory.php +++ b/tests/app/database/Factory/CommentFactory.php @@ -18,7 +18,7 @@ public function definition(): array { return [ 'text' => $this->faker->randomHtml(), - 'author' => UserFactory::new()->createOne(), + 'author' => UserFactory::new()->makeOne(), 'postedAt' => \DateTimeImmutable::createFromMutable($this->faker->dateTime()), ]; } diff --git a/tests/app/database/Factory/PostFactory.php b/tests/app/database/Factory/PostFactory.php index bb54d07..b9027fb 100644 --- a/tests/app/database/Factory/PostFactory.php +++ b/tests/app/database/Factory/PostFactory.php @@ -18,9 +18,9 @@ public function definition(): array { return [ 'content' => $this->faker->randomHtml(), - 'author' => UserFactory::new()->createOne(), + 'author' => UserFactory::new()->makeOne(), 'publishedAt' => \DateTimeImmutable::createFromMutable($this->faker->dateTime()), - 'comments' => CommentFactory::new()->times(3)->create(), + 'comments' => CommentFactory::new()->times(3)->make(), ]; } } diff --git a/tests/app/database/Factory/WithCompositePkFactory.php b/tests/app/database/Factory/WithCompositePkFactory.php new file mode 100644 index 0000000..a41e054 --- /dev/null +++ b/tests/app/database/Factory/WithCompositePkFactory.php @@ -0,0 +1,25 @@ + $this->faker->randomDigit(), + 'otherId' => $this->faker->randomDigit(), + 'content' => $this->faker->sentence, + ]; + } +} diff --git a/tests/app/database/Seeder/CommentSeeder.php b/tests/app/database/Seeder/CommentSeeder.php index 418d772..16be76f 100644 --- a/tests/app/database/Seeder/CommentSeeder.php +++ b/tests/app/database/Seeder/CommentSeeder.php @@ -18,9 +18,9 @@ class CommentSeeder extends AbstractSeeder public function run(): \Generator { /** @var Post $post */ - $post = PostFactory::new()->createOne(); + $post = PostFactory::new()->makeOne(); /** @var User $user */ - $user = UserFactory::new()->createOne(); + $user = UserFactory::new()->makeOne(); $comment = new Comment(); $comment->post = $post; diff --git a/tests/app/database/Seeder/PostSeeder.php b/tests/app/database/Seeder/PostSeeder.php index 9d22b85..8d6110c 100644 --- a/tests/app/database/Seeder/PostSeeder.php +++ b/tests/app/database/Seeder/PostSeeder.php @@ -13,6 +13,6 @@ class PostSeeder extends AbstractSeeder { public function run(): \Generator { - yield from PostFactory::new()->times(10)->create(); + yield from PostFactory::new()->times(10)->make(); } } diff --git a/tests/app/database/Seeder/UserSeeder.php b/tests/app/database/Seeder/UserSeeder.php index 9d24eb7..59133fc 100644 --- a/tests/app/database/Seeder/UserSeeder.php +++ b/tests/app/database/Seeder/UserSeeder.php @@ -13,6 +13,6 @@ class UserSeeder extends AbstractSeeder public function run(): \Generator { - yield UserFactory::new()->createOne(); + yield UserFactory::new()->makeOne(); } } diff --git a/tests/app/database/Seeder/WithCompositePkSeeder.php b/tests/app/database/Seeder/WithCompositePkSeeder.php new file mode 100644 index 0000000..dfb2e37 --- /dev/null +++ b/tests/app/database/Seeder/WithCompositePkSeeder.php @@ -0,0 +1,18 @@ +createOne(); + } +} diff --git a/tests/app/src/Database/User.php b/tests/app/src/Database/User.php index e00cf6a..fff6837 100644 --- a/tests/app/src/Database/User.php +++ b/tests/app/src/Database/User.php @@ -28,7 +28,7 @@ class User #[Column(type: 'int')] public int $age; - #[Column(type: 'bool')] + #[Column(type: 'boolean', typecast: 'bool')] public bool $active; #[Column(type: 'float')] diff --git a/tests/app/src/Database/WithCompositePk.php b/tests/app/src/Database/WithCompositePk.php new file mode 100644 index 0000000..a20cec4 --- /dev/null +++ b/tests/app/src/Database/WithCompositePk.php @@ -0,0 +1,23 @@ +refreshApp(); + $this->initApp(static::ENV); } } diff --git a/tests/src/Functional/Driver/Common/Factory/AbstractFactoryTest.php b/tests/src/Functional/Driver/Common/Factory/AbstractFactoryTest.php new file mode 100644 index 0000000..5faaae6 --- /dev/null +++ b/tests/src/Functional/Driver/Common/Factory/AbstractFactoryTest.php @@ -0,0 +1,34 @@ +times(5)->create(); + + $this->assertTableCount('users', 5); + $this->assertTableHas('users', ['id' => $users[0]->id]); + $this->assertTableHas('users', ['id' => $users[1]->id]); + $this->assertTableHas('users', ['id' => $users[2]->id]); + $this->assertTableHas('users', ['id' => $users[3]->id]); + $this->assertTableHas('users', ['id' => $users[4]->id]); + } + + public function testCreateOne(): void + { + $user = UserFactory::new()->createOne(); + + $this->assertTableCount('users', 1); + $this->assertTableHas('users', ['id' => $user->id]); + } +} diff --git a/tests/src/Functional/Driver/Common/Seeder/ExecutorTest.php b/tests/src/Functional/Driver/Common/Seeder/ExecutorTest.php new file mode 100644 index 0000000..d884a8f --- /dev/null +++ b/tests/src/Functional/Driver/Common/Seeder/ExecutorTest.php @@ -0,0 +1,66 @@ +getContainer()->get(Executor::class); + $method = new \ReflectionMethod($executor, 'isNotExists'); + + $compositePk = WithCompositePkFactory::new()->makeOne(); + $compositePk->id = 1; + $compositePk->otherId = 3; + + $simplePk = UserFactory::new()->makeOne(); + $simplePk->id = 1; + + $this->assertTrue($method->invoke($executor, $compositePk)); + $this->assertTrue($method->invoke($executor, $simplePk)); + } + + public function testIsExists(): void + { + $executor = $this->getContainer()->get(Executor::class); + $method = new \ReflectionMethod($executor, 'isNotExists'); + + $compositePk = WithCompositePkFactory::new()->createOne(); + $simplePk = UserFactory::new()->createOne(); + + $this->assertFalse($method->invoke($executor, $compositePk)); + $this->assertFalse($method->invoke($executor, $simplePk)); + } + + public function testSeed(): void + { + $this->assertTableCount('users', 0); + + $executor = $this->getContainer()->get(Executor::class); + $executor->execute([new UserSeeder()]); + + $this->assertTableCount('users', 1); + } + + public function testSeedWithMethodCreateAndCompositePk(): void + { + $this->assertTableCount('composite_pk', 0); + + $executor = $this->getContainer()->get(Executor::class); + $executor->execute([new WithCompositePkSeeder()]); + + $this->assertTableCount('composite_pk', 1); + } +} diff --git a/tests/src/Functional/Driver/SQLite/Factory/AbstractFactoryTest.php b/tests/src/Functional/Driver/SQLite/Factory/AbstractFactoryTest.php new file mode 100644 index 0000000..17351a9 --- /dev/null +++ b/tests/src/Functional/Driver/SQLite/Factory/AbstractFactoryTest.php @@ -0,0 +1,17 @@ +getContainer()->get(Database::class)->hasTable($table), - \sprintf('Table [%s] does not exist.', $table) - ); - } - - public function assertTableIsNotExists(string $table): void - { - static::assertFalse( - $this->getContainer()->get(Database::class)->hasTable($table), - \sprintf('Table [%s] exists.', $table) - ); - } } diff --git a/tests/src/Unit/Factory/FactoryTest.php b/tests/src/Unit/Factory/FactoryTest.php index f0c13ef..7e94527 100644 --- a/tests/src/Unit/Factory/FactoryTest.php +++ b/tests/src/Unit/Factory/FactoryTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Factory; use PHPUnit\Framework\TestCase; +use Spiral\DatabaseSeeder\Factory\Exception\FactoryException; use Tests\App\Database\Comment; use Tests\App\Database\Post; use Tests\App\Database\User; @@ -16,9 +17,9 @@ final class FactoryTest extends TestCase { public function testCreateEntity(): void { - $user = UserFactory::new()->createOne(); - $post = PostFactory::new()->createOne(); - $comment = CommentFactory::new()->createOne(); + $user = UserFactory::new()->makeOne(); + $post = PostFactory::new()->makeOne(); + $comment = CommentFactory::new()->makeOne(); $this->assertInstanceOf(User::class, $user); $this->assertIsString($user->firstName); @@ -41,7 +42,7 @@ public function testCreateEntity(): void public function testCreateMultiple(): void { - $users = UserFactory::new()->times(2)->create(); + $users = UserFactory::new()->times(2)->make(); $this->assertCount(2, $users); @@ -55,22 +56,38 @@ public function testCreateMultiple(): void public function testCreateNullableNotFilled(): void { - $user = UserFactory::new()->createOne(); + $user = UserFactory::new()->makeOne(); $this->assertNull($user->city); } public function testAfterCreateCallback(): void { - $post = PostFactory::new()->afterCreate(fn(Post $post) => $post->content = 'changed by callback')->createOne(); + $post = PostFactory::new()->afterCreate(fn(Post $post) => $post->content = 'changed by callback')->makeOne(); $this->assertSame('changed by callback', $post->content); } public function testCreateWithReplaces(): void { - $post = PostFactory::new(['content' => 'changed by replaces array'])->createOne(); + $post = PostFactory::new(['content' => 'changed by replaces array'])->makeOne(); $this->assertSame('changed by replaces array', $post->content); } + + public function testRawData(): void + { + $post = PostFactory::new()->data; + $post2 = PostFactory::new()->data; + + $this->assertIsArray($post); + $this->assertIsArray($post2); + $this->assertNotSame($post['content'], $post2['content']); + } + + public function testUndefinedProperty(): void + { + $this->expectException(FactoryException::class); + PostFactory::new()->test; + } }