Skip to content

Commit ca95279

Browse files
nikophilkbond
authored andcommitted
feat: Introduce #[AsFixture] attribute and foundry:load-fixture command (#903)
1 parent d244b9b commit ca95279

16 files changed

+814
-127
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: 47 additions & 49 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.
@@ -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

@@ -2450,6 +2401,53 @@ You can use the ``#[WithStory]`` attribute to load stories in your tests:
24502401

24512402
If used on the class, the story will be loaded before each test method.
24522403

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

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)