Skip to content
Open
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
8 changes: 8 additions & 0 deletions docs/en/guides/seeding.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@ bin/cake seeds reset
> When re-running seeds with `--force`, be careful to ensure your seeds are
> idempotent (safe to run multiple times) or they may create duplicate data.

Plugin seeds are tracked with their plugin name, so the `plugin` option is
required to see or reset them:

```bash
bin/cake seeds status --plugin PluginName
bin/cake seeds reset --plugin PluginName
```

### Customizing the Seed Tracking Table

By default, seed execution is tracked in a table named `cake_seeds`. You can
Expand Down
15 changes: 2 additions & 13 deletions src/Command/SeedStatusCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use Cake\Console\Arguments;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\Core\Configure;
use Migrations\Config\ConfigInterface;
use Migrations\Migration\ManagerFactory;
use Migrations\Util\Util;
Expand Down Expand Up @@ -106,24 +105,14 @@ public function execute(Arguments $args, ConsoleIo $io): ?int

// Build status list
$statuses = [];
$appNamespace = Configure::read('App.namespace', 'App');
foreach ($seeds as $seed) {
$plugin = null;
$className = $seed::class;

if (str_contains($className, '\\')) {
$parts = explode('\\', $className);
if (count($parts) > 1 && $parts[0] !== $appNamespace) {
$plugin = $parts[0];
}
}

$plugin = Util::getSeedPlugin($seed);
$seedName = $seed->getName();
$executed = false;
$executedAt = null;

foreach ($seedLog as $entry) {
if ($entry['seed_name'] === $seedName && $entry['plugin'] === $plugin) {
if ($entry['seed_name'] === $seedName && Util::matchesSeedPlugin($entry['plugin'], $plugin)) {
$executed = true;
$executedAt = $entry['executed_at'];
break;
Expand Down
61 changes: 36 additions & 25 deletions src/Db/Adapter/AbstractAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
use Migrations\Db\Table\View;
use Migrations\MigrationInterface;
use Migrations\SeedInterface;
use Migrations\Util\Util;
use PDOException;
use RuntimeException;
use function Cake\Core\deprecationWarning;
Expand Down Expand Up @@ -1096,17 +1097,7 @@ public function getSeedLog(): array
*/
public function seedExecuted(SeedInterface $seed, string $executedTime): AdapterInterface
{
$plugin = null;
$className = $seed::class;

if (str_contains($className, '\\')) {
$parts = explode('\\', $className);
$appNamespace = Configure::read('App.namespace', 'App');
if (count($parts) > 1 && $parts[0] !== $appNamespace) {
$plugin = $parts[0];
}
}

$plugin = $this->resolveSeedPlugin($seed);
$seedName = substr($seed->getName(), 0, 100);

$query = $this->getInsertBuilder();
Expand All @@ -1127,31 +1118,51 @@ public function seedExecuted(SeedInterface $seed, string $executedTime): Adapter
*/
public function removeSeedFromLog(SeedInterface $seed): AdapterInterface
{
$plugin = null;
$className = $seed::class;
$plugin = $this->resolveSeedPlugin($seed);
$seedName = $seed->getName();

if (str_contains($className, '\\')) {
$parts = explode('\\', $className);
$appNamespace = Configure::read('App.namespace', 'App');
if (count($parts) > 1 && $parts[0] !== $appNamespace) {
$plugin = $parts[0];
}
$conditions = ['seed_name' => $seedName];
if ($plugin !== null) {
// Also remove entries logged before plugin attribution was fixed.
$conditions['OR'] = [
'plugin' => $plugin,
'plugin IS' => null,
];
} else {
$conditions['plugin IS'] = null;
}

$seedName = $seed->getName();

$query = $this->getDeleteBuilder();
$query->delete()
->from($this->getSeedSchemaTableName())
->where([
'seed_name' => $seedName,
'plugin IS' => $plugin,
]);
->where($conditions);
$this->executeQuery($query);

return $this;
}

/**
* Resolve the plugin a seed belongs to.
*
* Seed classes are not namespaced, so the plugin cannot be derived from the class
* name. The plugin of the current run is used instead, matching how migrations are
* tracked.
*
* @param \Migrations\SeedInterface $seed The seed to resolve the plugin for.
* @return string|null The plugin name or null for application seeds.
*/
protected function resolveSeedPlugin(SeedInterface $seed): ?string
{
$plugin = Util::getSeedPlugin($seed);
if ($plugin !== null) {
return $plugin;
}

$option = $this->getOption('plugin');

return $option ? (string)$option : null;
}

/**
* {@inheritDoc}
*
Expand Down
15 changes: 2 additions & 13 deletions src/Migration/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

use Cake\Console\Arguments;
use Cake\Console\ConsoleIo;
use Cake\Core\Configure;
use DateTime;
use Exception;
use InvalidArgumentException;
Expand Down Expand Up @@ -214,21 +213,11 @@ public function isSeedExecuted(SeedInterface $seed): bool

$seedLog = $adapter->getSeedLog();

$plugin = null;
$className = $seed::class;

if (str_contains($className, '\\')) {
$parts = explode('\\', $className);
$appNamespace = Configure::read('App.namespace', 'App');
if (count($parts) > 1 && $parts[0] !== $appNamespace) {
$plugin = $parts[0];
}
}

$plugin = Util::getSeedPlugin($seed);
$seedName = $seed->getName();

foreach ($seedLog as $entry) {
if ($entry['seed_name'] === $seedName && $entry['plugin'] === $plugin) {
if ($entry['seed_name'] === $seedName && Util::matchesSeedPlugin($entry['plugin'], $plugin)) {
return true;
}
}
Expand Down
42 changes: 42 additions & 0 deletions src/Util/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
use Cake\Utility\Inflector;
use DateTime;
use DateTimeZone;
use Migrations\Config\ConfigInterface;
use Migrations\Db\Adapter\UnifiedMigrationsTableStorage;
use Migrations\SeedInterface;
use RuntimeException;

/**
Expand Down Expand Up @@ -208,6 +210,46 @@ public static function getSeedDisplayName(string $seedName): string
return $seedName;
}

/**
* Get the plugin a seed belongs to.
*
* Seed classes are not namespaced, so the plugin cannot be derived from the class
* name. The plugin of the run the seed was loaded in is used instead.
*
* @param \Migrations\SeedInterface $seed The seed to get the plugin for.
* @return string|null The plugin name, or null for application seeds.
*/
public static function getSeedPlugin(SeedInterface $seed): ?string
{
$config = $seed->getConfig();
if (!$config instanceof ConfigInterface || !isset($config['plugin'])) {
return null;
}

return (string)$config['plugin'] ?: null;
}

/**
* Check whether a seed log entry belongs to the given plugin.
*
* Seeds executed before plugin attribution was fixed were logged without a plugin.
* Those entries are still matched for plugin seeds so that they are not executed twice.
* As a trade-off, an application seed sharing its name with a plugin seed can be
* matched as well, which is preferred over re-running a seed that already ran.
*
* @param string|null $entryPlugin The plugin stored in the seed log entry.
* @param string|null $plugin The plugin of the seed being checked.
* @return bool
*/
public static function matchesSeedPlugin(?string $entryPlugin, ?string $plugin): bool
{
if ($entryPlugin === $plugin) {
return true;
}

return $plugin !== null && $entryPlugin === null;
}

/**
* Expands a set of paths with curly braces (if supported by the OS).
*
Expand Down
96 changes: 96 additions & 0 deletions tests/TestCase/Command/SeedCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,102 @@ public function testSeedStatusCommand(): void
$this->assertOutputContains('Numbers');
}

public function testSeederPluginIsTrackedWithPluginName(): void
{
$this->_loadTestPlugin('TestBlog');
$this->createTables();

$this->exec('seeds run -c test -p TestBlog --source CallSeeds PluginLettersSeed');
$this->assertExitSuccess();

/** @var \Cake\Database\Connection $connection */
$connection = ConnectionManager::get('test');
$rows = $connection
->execute('SELECT seed_name, plugin FROM cake_seeds ORDER BY seed_name')
->fetchAll('assoc');

$expected = [
['seed_name' => 'PluginLettersSeed', 'plugin' => 'TestBlog'],
['seed_name' => 'PluginSubLettersSeed', 'plugin' => 'TestBlog'],
];
$this->assertSame($expected, $rows);
}

public function testSeederPluginLegacyLogEntryIsNotRunAgain(): void
{
$this->_loadTestPlugin('TestBlog');
$this->createTables();

/** @var \Cake\Database\Connection $connection */
$connection = ConnectionManager::get('test');

// Simulate seeds executed before plugin attribution was fixed
$this->exec('seeds run -c test -p TestBlog --source CallSeeds PluginLettersSeed');
$this->assertExitSuccess();
$connection->execute('UPDATE cake_seeds SET plugin = NULL');

$letters = $connection->execute('SELECT COUNT(*) FROM letters');
$this->assertEquals(4, $letters->fetchColumn(0));

$this->exec('seeds run -c test -p TestBlog --source CallSeeds PluginLettersSeed');
$this->assertExitSuccess();
$this->assertOutputNotContains('seeding');

// No additional rows were inserted by a second run
$letters = $connection->execute('SELECT COUNT(*) FROM letters');
$this->assertEquals(4, $letters->fetchColumn(0));

$this->exec('seeds status -c test -p TestBlog --source CallSeeds');
$this->assertExitSuccess();
$this->assertOutputContains('executed');
$this->assertOutputNotContains('pending');

// Resetting removes the legacy entries as well
$this->exec('seeds reset -c test -p TestBlog --source CallSeeds', ['y']);
$this->assertExitSuccess();

$seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds');
$this->assertEquals(0, $seedLog->fetchColumn(0));
}

public function testSeedStatusCommandWithPlugin(): void
{
$this->_loadTestPlugin('TestBlog');
$this->createTables();

$this->exec('seeds run -c test -p TestBlog --source CallSeeds PluginLettersSeed');
$this->assertExitSuccess();

$this->exec('seeds status -c test -p TestBlog --source CallSeeds');
$this->assertExitSuccess();
$this->assertOutputContains('TestBlog');
$this->assertOutputContains('executed');
$this->assertOutputNotContains('pending');
}

public function testSeedResetCommandWithPlugin(): void
{
$this->_loadTestPlugin('TestBlog');
$this->createTables();

$this->exec('seeds run -c test -p TestBlog --source CallSeeds PluginLettersSeed');
$this->assertExitSuccess();

$this->exec('seeds reset -c test -p TestBlog --source CallSeeds', ['y']);
$this->assertExitSuccess();

/** @var \Cake\Database\Connection $connection */
$connection = ConnectionManager::get('test');
$seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds');
$this->assertEquals(0, $seedLog->fetchColumn(0));

// Verify the seed can be run again without --force
$this->exec('seeds run -c test -p TestBlog --source CallSeeds PluginLettersSeed');
$this->assertExitSuccess();
$this->assertOutputContains('seeding');
$this->assertOutputNotContains('already executed');
}

public function testSeedResetCommand(): void
{
$this->createTables();
Expand Down
Loading