Skip to content

feat: Introduce #[AsFixture] attribute and foundry:load-fixture command #903

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions config/persistence.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use Zenstruck\Foundry\Command\LoadStoryCommand;
use Zenstruck\Foundry\Persistence\PersistenceManager;
use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager;

Expand All @@ -17,5 +18,14 @@
tagged_iterator('.foundry.persistence.database_resetter'),
tagged_iterator('.foundry.persistence.schema_resetter'),
])

->set('.zenstruck_foundry.story.load_story-command', LoadStoryCommand::class)
->arg('$databaseResetters', tagged_iterator('.foundry.persistence.database_resetter'))
->arg('$kernel', service('kernel'))
->tag('console.command', [
'command' => 'foundry:load-stories',
'aliases' => ['foundry:load-fixtures', 'foundry:load-fixture', 'foundry:load-story'],
'description' => 'Load stories which are marked with #[AsFixture] attribute.',
])
;
};
98 changes: 48 additions & 50 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ Foundry
Foundry makes creating fixtures data fun again, via an expressive, auto-completable, on-demand fixtures system with
Symfony and Doctrine:

The factories can be used inside `DoctrineFixturesBundle <https://symfony.com/bundles/DoctrineFixturesBundle/current/index.html>`_
to load fixtures or inside your tests, :ref:`where it has even more features <using-in-your-tests>`.

Foundry supports ``doctrine/orm`` (with `doctrine/doctrine-bundle <https://github.com/doctrine/doctrinebundle>`_),
``doctrine/mongodb-odm`` (with `doctrine/mongodb-odm-bundle <https://github.com/doctrine/DoctrineMongoDBBundle>`_)
or a combination of these.
Expand Down Expand Up @@ -1163,7 +1160,7 @@ once. To do this, wrap the operations in a ``flush_after()`` callback:
TagFactory::createMany(200); // instantiated/persisted but not flushed
}); // single flush

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

::

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

Using with DoctrineFixturesBundle
---------------------------------

Foundry works out of the box with `DoctrineFixturesBundle <https://symfony.com/bundles/DoctrineFixturesBundle/current/index.html>`_.
You can simply use your factories and stories right within your fixture files:

::

// 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()->range(0, 10),
];
});
}
}

Run the ``doctrine:fixtures:load`` as normal to seed your database.

Using in your Tests
-------------------

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

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

Loading stories as fixtures in your database
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. versionadded:: 2.6

Command ``foundry:load-stories`` and attribute ``#[AsFixture]`` were added in 2.6.

Using command ``bin/console foundry:load-stories``, you can load stories as fixtures in your database.
This is mainly useful to load fixtures in "dev" mode.

Mark with the attribute ``#[AsFixture]`` the stories your want to be loaded by the command:

::

use Zenstruck\Foundry\Attribute\AsFixture;

#[AsFixture(name: 'category')]
final class CategoryStory extends Story
{
// ...
}

``bin/console foundry:load-stories category`` will now load the story ``CategoryStory`` in your database.

.. note::

If only a single story exists, you can omit the argument and just call ``bin/console foundry:load-stories`` to load it.

You can also load stories by group, by using the ``groups`` option:

::

use Zenstruck\Foundry\Attribute\AsFixture;

#[AsFixture(name: 'category', groups: ['all-stories'])]
final class CategoryStory extends Story {}

#[AsFixture(name: 'post', groups: ['all-stories'])]
final class PostStory extends Story {}

``bin/console foundry:load-stories all-stories`` will load both stories ``CategoryStory`` and ``PostStory``.

.. tip::

It is possible to call a story inside another story, by using `OtherStory::load();`. Because the stories are only
loaded once, it will work regardless of the order of the stories.

Static Analysis
---------------

Expand Down
28 changes: 28 additions & 0 deletions src/Attribute/AsFixture.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Foundry\Attribute;

/**
* @author Nicolas PHILIPPE <[email protected]>
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final class AsFixture
{
public function __construct(
public readonly string $name,
/** @var list<string> */
public readonly array $groups = [],
) {
}
}
121 changes: 121 additions & 0 deletions src/Command/LoadStoryCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Foundry\Command;

use DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\HttpKernel\KernelInterface;
use Zenstruck\Foundry\Persistence\ResetDatabase\BeforeFirstTestResetter;
use Zenstruck\Foundry\Story;

/**
* @author Nicolas PHILIPPE <[email protected]>
*/
final class LoadStoryCommand extends Command
{
public function __construct(
/** @var array<string, class-string<Story>> */
private readonly array $stories,
/** @var array<string, array<string, class-string<Story>>> */
private readonly array $groupedStories,
/** @var iterable<BeforeFirstTestResetter> */
private iterable $databaseResetters,
private KernelInterface $kernel,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->addArgument('name', InputArgument::OPTIONAL, 'The name of the story to load.')
->addOption('append', 'a', InputOption::VALUE_NONE, 'Skip resetting database and append data to the existing database.')
;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
if (0 === \count($this->stories)) {
throw new LogicException('No story as fixture available: add attribute #[AsFixture] to your story classes before running this command.');
}

$io = new SymfonyStyle($input, $output);

if (!$input->getOption('append')) {
$this->resetDatabase();
}

$stories = [];

if (null === ($name = $input->getArgument('name'))) {
if (1 === \count($this->stories)) {
$name = \array_keys($this->stories)[0];
} else {
$storyNames = \array_keys($this->stories);
if (\count($this->groupedStories) > 0) {
$storyNames[] = '(choose a group of stories...)';
}
$name = $io->choice('Choose a story to load:', $storyNames);
}

if (!isset($this->stories[$name])) {
$groupsNames = \array_keys($this->groupedStories);
$name = $io->choice('Choose a group of stories:', $groupsNames);
}
}

if (isset($this->stories[$name])) {
$io->comment("Loading story with name \"{$name}\"...");
$stories = [$name => $this->stories[$name]];
}

if (isset($this->groupedStories[$name])) {
$io->comment("Loading stories group \"{$name}\"...");
$stories = $this->groupedStories[$name];
}

if (!$stories) {
throw new InvalidArgumentException("Story with name \"{$name}\" does not exist.");
}

foreach ($stories as $name => $storyClass) {
$storyClass::load();

if ($io->isVerbose()) {
$io->info("Story \"{$storyClass}\" loaded (name: {$name}).");
}
}

$io->success('Stories successfully loaded!');

return self::SUCCESS;
}

private function resetDatabase(): void
{
// it is very not likely that we need dama when running this command
if (\class_exists(StaticDriver::class) && StaticDriver::isKeepStaticConnections()) {
StaticDriver::setKeepStaticConnections(false);
}

foreach ($this->databaseResetters as $databaseResetter) {
$databaseResetter->resetBeforeFirstTest($this->kernel);
}
}
}
65 changes: 65 additions & 0 deletions src/DependencyInjection/AsFixtureStoryCompilerPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Zenstruck\Foundry\DependencyInjection;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Reference;

final class AsFixtureStoryCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->has('.zenstruck_foundry.story.load_story-command')) {
return;
}

/** @var array<string, Reference> $fixtureStories */
$fixtureStories = [];
$groupedFixtureStories = [];
foreach ($container->findTaggedServiceIds('foundry.story.fixture') as $id => $tags) {
if (1 !== \count($tags)) {
throw new LogicException('Tag "foundry.story.fixture" must be used only once per service.');
}

$name = $tags[0]['name'];

if (isset($fixtureStories[$name])) {
throw new LogicException("Cannot use #[AsFixture] name \"{$name}\" for service \"{$id}\". This name is already used by service \"{$fixtureStories[$name]}\".");
}

$storyClass = $container->findDefinition($id)->getClass();

$fixtureStories[$name] = $storyClass;

$groups = $tags[0]['groups'];
if (!$groups) {
continue;
}

foreach ($groups as $group) {
$groupedFixtureStories[$group] ??= [];
$groupedFixtureStories[$group][$name] = $storyClass;
}
}

if ($collisionNames = \array_intersect(\array_keys($fixtureStories), \array_keys($groupedFixtureStories))) {
$collisionNames = \implode('", "', $collisionNames);
throw new LogicException("Cannot use #[AsFixture] group(s) \"{$collisionNames}\", they collide with fixture names.");
}

$container->findDefinition('.zenstruck_foundry.story.load_story-command')
->setArgument('$stories', $fixtureStories)
->setArgument('$groupedStories', $groupedFixtureStories);
}
}
Loading
Loading