Skip to content

Commit 543b845

Browse files
authored
feat: Introduce #[AsFixture] attribute and foundry:load-fixture command (#903)
1 parent cdbacdd commit 543b845

16 files changed

+815
-128
lines changed

config/persistence.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
44

5+
use Zenstruck\Foundry\Command\LoadStoryCommand;
56
use Zenstruck\Foundry\Persistence\PersistenceManager;
67
use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager;
78

@@ -17,5 +18,14 @@
1718
tagged_iterator('.foundry.persistence.database_resetter'),
1819
tagged_iterator('.foundry.persistence.schema_resetter'),
1920
])
21+
22+
->set('.zenstruck_foundry.story.load_story-command', LoadStoryCommand::class)
23+
->arg('$databaseResetters', tagged_iterator('.foundry.persistence.database_resetter'))
24+
->arg('$kernel', service('kernel'))
25+
->tag('console.command', [
26+
'command' => 'foundry:load-stories',
27+
'aliases' => ['foundry:load-fixtures', 'foundry:load-fixture', 'foundry:load-story'],
28+
'description' => 'Load stories which are marked with #[AsFixture] attribute.',
29+
])
2030
;
2131
};

docs/index.rst

Lines changed: 48 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ Foundry
44
Foundry makes creating fixtures data fun again, via an expressive, auto-completable, on-demand fixtures system with
55
Symfony and Doctrine:
66

7-
The factories can be used inside `DoctrineFixturesBundle <https://symfony.com/bundles/DoctrineFixturesBundle/current/index.html>`_
8-
to load fixtures or inside your tests, :ref:`where it has even more features <using-in-your-tests>`.
9-
107
Foundry supports ``doctrine/orm`` (with `doctrine/doctrine-bundle <https://github.com/doctrine/doctrinebundle>`_),
118
``doctrine/mongodb-odm`` (with `doctrine/mongodb-odm-bundle <https://github.com/doctrine/DoctrineMongoDBBundle>`_)
129
or a combination of these.
@@ -1163,7 +1160,7 @@ once. To do this, wrap the operations in a ``flush_after()`` callback:
11631160
TagFactory::createMany(200); // instantiated/persisted but not flushed
11641161
}); // single flush
11651162

1166-
The ``flush_after()`` function forwards the callbacks return, in case you need to use the objects in your tests:
1163+
The ``flush_after()`` function forwards the callback's return, in case you need to use the objects in your tests:
11671164

11681165
::
11691166

@@ -1284,52 +1281,6 @@ You can even create associative arrays, with the nice DX provided by Foundry:
12841281
// will create ['prop1' => 'foo', 'prop2' => 'default value 2']
12851282
$array = SomeArrayFactory::createOne(['prop1' => 'foo']);
12861283

1287-
Using with DoctrineFixturesBundle
1288-
---------------------------------
1289-
1290-
Foundry works out of the box with `DoctrineFixturesBundle <https://symfony.com/bundles/DoctrineFixturesBundle/current/index.html>`_.
1291-
You can simply use your factories and stories right within your fixture files:
1292-
1293-
::
1294-
1295-
// src/DataFixtures/AppFixtures.php
1296-
namespace App\DataFixtures;
1297-
1298-
use App\Factory\CategoryFactory;
1299-
use App\Factory\CommentFactory;
1300-
use App\Factory\PostFactory;
1301-
use App\Factory\TagFactory;
1302-
use Doctrine\Bundle\FixturesBundle\Fixture;
1303-
use Doctrine\Persistence\ObjectManager;
1304-
1305-
class AppFixtures extends Fixture
1306-
{
1307-
public function load(ObjectManager $manager)
1308-
{
1309-
// create 10 Category's
1310-
CategoryFactory::createMany(10);
1311-
1312-
// create 20 Tag's
1313-
TagFactory::createMany(20);
1314-
1315-
// create 50 Post's
1316-
PostFactory::createMany(50, function() {
1317-
return [
1318-
// each Post will have a random Category (chosen from those created above)
1319-
'category' => CategoryFactory::random(),
1320-
1321-
// each Post will have between 0 and 6 Tag's (chosen from those created above)
1322-
'tags' => TagFactory::randomRange(0, 6),
1323-
1324-
// each Post will have between 0 and 10 Comment's that are created new
1325-
'comments' => CommentFactory::new()->range(0, 10),
1326-
];
1327-
});
1328-
}
1329-
}
1330-
1331-
Run the ``doctrine:fixtures:load`` as normal to seed your database.
1332-
13331284
Using in your Tests
13341285
-------------------
13351286

@@ -2436,6 +2387,53 @@ You can use the ``#[WithStory]`` attribute to load stories in your tests:
24362387

24372388
If used on the class, the story will be loaded before each test method.
24382389

2390+
Loading stories as fixtures in your database
2391+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2392+
2393+
.. versionadded:: 2.6
2394+
2395+
Command ``foundry:load-stories`` and attribute ``#[AsFixture]`` were added in 2.6.
2396+
2397+
Using command ``bin/console foundry:load-stories``, you can load stories as fixtures in your database.
2398+
This is mainly useful to load fixtures in "dev" mode.
2399+
2400+
Mark with the attribute ``#[AsFixture]`` the stories your want to be loaded by the command:
2401+
2402+
::
2403+
2404+
use Zenstruck\Foundry\Attribute\AsFixture;
2405+
2406+
#[AsFixture(name: 'category')]
2407+
final class CategoryStory extends Story
2408+
{
2409+
// ...
2410+
}
2411+
2412+
``bin/console foundry:load-stories category`` will now load the story ``CategoryStory`` in your database.
2413+
2414+
.. note::
2415+
2416+
If only a single story exists, you can omit the argument and just call ``bin/console foundry:load-stories`` to load it.
2417+
2418+
You can also load stories by group, by using the ``groups`` option:
2419+
2420+
::
2421+
2422+
use Zenstruck\Foundry\Attribute\AsFixture;
2423+
2424+
#[AsFixture(name: 'category', groups: ['all-stories'])]
2425+
final class CategoryStory extends Story {}
2426+
2427+
#[AsFixture(name: 'post', groups: ['all-stories'])]
2428+
final class PostStory extends Story {}
2429+
2430+
``bin/console foundry:load-stories all-stories`` will load both stories ``CategoryStory`` and ``PostStory``.
2431+
2432+
.. tip::
2433+
2434+
It is possible to call a story inside another story, by using `OtherStory::load();`. Because the stories are only
2435+
loaded once, it will work regardless of the order of the stories.
2436+
24392437
Static Analysis
24402438
---------------
24412439

src/Attribute/AsFixture.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the zenstruck/foundry package.
7+
*
8+
* (c) Kevin Bond <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Zenstruck\Foundry\Attribute;
15+
16+
/**
17+
* @author Nicolas PHILIPPE <[email protected]>
18+
*/
19+
#[\Attribute(\Attribute::TARGET_CLASS)]
20+
final class AsFixture
21+
{
22+
public function __construct(
23+
public readonly string $name,
24+
/** @var list<string> */
25+
public readonly array $groups = [],
26+
) {
27+
}
28+
}

src/Command/LoadStoryCommand.php

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the zenstruck/foundry package.
5+
*
6+
* (c) Kevin Bond <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Zenstruck\Foundry\Command;
13+
14+
use DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Exception\InvalidArgumentException;
17+
use Symfony\Component\Console\Exception\LogicException;
18+
use Symfony\Component\Console\Input\InputArgument;
19+
use Symfony\Component\Console\Input\InputInterface;
20+
use Symfony\Component\Console\Input\InputOption;
21+
use Symfony\Component\Console\Output\OutputInterface;
22+
use Symfony\Component\Console\Style\SymfonyStyle;
23+
use Symfony\Component\HttpKernel\KernelInterface;
24+
use Zenstruck\Foundry\Persistence\ResetDatabase\BeforeFirstTestResetter;
25+
use Zenstruck\Foundry\Story;
26+
27+
/**
28+
* @author Nicolas PHILIPPE <[email protected]>
29+
*/
30+
final class LoadStoryCommand extends Command
31+
{
32+
public function __construct(
33+
/** @var array<string, class-string<Story>> */
34+
private readonly array $stories,
35+
/** @var array<string, array<string, class-string<Story>>> */
36+
private readonly array $groupedStories,
37+
/** @var iterable<BeforeFirstTestResetter> */
38+
private iterable $databaseResetters,
39+
private KernelInterface $kernel,
40+
) {
41+
parent::__construct();
42+
}
43+
44+
protected function configure(): void
45+
{
46+
$this
47+
->addArgument('name', InputArgument::OPTIONAL, 'The name of the story to load.')
48+
->addOption('append', 'a', InputOption::VALUE_NONE, 'Skip resetting database and append data to the existing database.')
49+
;
50+
}
51+
52+
protected function execute(InputInterface $input, OutputInterface $output): int
53+
{
54+
if (0 === \count($this->stories)) {
55+
throw new LogicException('No story as fixture available: add attribute #[AsFixture] to your story classes before running this command.');
56+
}
57+
58+
$io = new SymfonyStyle($input, $output);
59+
60+
if (!$input->getOption('append')) {
61+
$this->resetDatabase();
62+
}
63+
64+
$stories = [];
65+
66+
if (null === ($name = $input->getArgument('name'))) {
67+
if (1 === \count($this->stories)) {
68+
$name = \array_keys($this->stories)[0];
69+
} else {
70+
$storyNames = \array_keys($this->stories);
71+
if (\count($this->groupedStories) > 0) {
72+
$storyNames[] = '(choose a group of stories...)';
73+
}
74+
$name = $io->choice('Choose a story to load:', $storyNames);
75+
}
76+
77+
if (!isset($this->stories[$name])) {
78+
$groupsNames = \array_keys($this->groupedStories);
79+
$name = $io->choice('Choose a group of stories:', $groupsNames);
80+
}
81+
}
82+
83+
if (isset($this->stories[$name])) {
84+
$io->comment("Loading story with name \"{$name}\"...");
85+
$stories = [$name => $this->stories[$name]];
86+
}
87+
88+
if (isset($this->groupedStories[$name])) {
89+
$io->comment("Loading stories group \"{$name}\"...");
90+
$stories = $this->groupedStories[$name];
91+
}
92+
93+
if (!$stories) {
94+
throw new InvalidArgumentException("Story with name \"{$name}\" does not exist.");
95+
}
96+
97+
foreach ($stories as $name => $storyClass) {
98+
$storyClass::load();
99+
100+
if ($io->isVerbose()) {
101+
$io->info("Story \"{$storyClass}\" loaded (name: {$name}).");
102+
}
103+
}
104+
105+
$io->success('Stories successfully loaded!');
106+
107+
return self::SUCCESS;
108+
}
109+
110+
private function resetDatabase(): void
111+
{
112+
// it is very not likely that we need dama when running this command
113+
if (\class_exists(StaticDriver::class) && StaticDriver::isKeepStaticConnections()) {
114+
StaticDriver::setKeepStaticConnections(false);
115+
}
116+
117+
foreach ($this->databaseResetters as $databaseResetter) {
118+
$databaseResetter->resetBeforeFirstTest($this->kernel);
119+
}
120+
}
121+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the zenstruck/foundry package.
5+
*
6+
* (c) Kevin Bond <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Zenstruck\Foundry\DependencyInjection;
13+
14+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
15+
use Symfony\Component\DependencyInjection\ContainerBuilder;
16+
use Symfony\Component\DependencyInjection\Exception\LogicException;
17+
use Symfony\Component\DependencyInjection\Reference;
18+
19+
final class AsFixtureStoryCompilerPass implements CompilerPassInterface
20+
{
21+
public function process(ContainerBuilder $container): void
22+
{
23+
if (!$container->has('.zenstruck_foundry.story.load_story-command')) {
24+
return;
25+
}
26+
27+
/** @var array<string, Reference> $fixtureStories */
28+
$fixtureStories = [];
29+
$groupedFixtureStories = [];
30+
foreach ($container->findTaggedServiceIds('foundry.story.fixture') as $id => $tags) {
31+
if (1 !== \count($tags)) {
32+
throw new LogicException('Tag "foundry.story.fixture" must be used only once per service.');
33+
}
34+
35+
$name = $tags[0]['name'];
36+
37+
if (isset($fixtureStories[$name])) {
38+
throw new LogicException("Cannot use #[AsFixture] name \"{$name}\" for service \"{$id}\". This name is already used by service \"{$fixtureStories[$name]}\".");
39+
}
40+
41+
$storyClass = $container->findDefinition($id)->getClass();
42+
43+
$fixtureStories[$name] = $storyClass;
44+
45+
$groups = $tags[0]['groups'];
46+
if (!$groups) {
47+
continue;
48+
}
49+
50+
foreach ($groups as $group) {
51+
$groupedFixtureStories[$group] ??= [];
52+
$groupedFixtureStories[$group][$name] = $storyClass;
53+
}
54+
}
55+
56+
if ($collisionNames = \array_intersect(\array_keys($fixtureStories), \array_keys($groupedFixtureStories))) {
57+
$collisionNames = \implode('", "', $collisionNames);
58+
throw new LogicException("Cannot use #[AsFixture] group(s) \"{$collisionNames}\", they collide with fixture names.");
59+
}
60+
61+
$container->findDefinition('.zenstruck_foundry.story.load_story-command')
62+
->setArgument('$stories', $fixtureStories)
63+
->setArgument('$groupedStories', $groupedFixtureStories);
64+
}
65+
}

0 commit comments

Comments
 (0)