diff --git a/README.md b/README.md index 326cb9a..37b7f8d 100644 --- a/README.md +++ b/README.md @@ -386,6 +386,34 @@ The console output will mark the seed step as `CONTINUE` if the step it already Database\Seeders\UserSeeder ................................... 32 ms DONE +### Recovering from Unique Constraints Violations + +Sometimes a Seed Step may throw a _Unique Constraints Violation_ exception, which happens when trying to insert a value that already exists on _unique_ column, like primary keys. It's not too common, but it usually happens when a random generator mistakenly repeats a value, like emails or text. + +[If transactions are not disabled](#disable-transactions), the Populator will retry the Seed Step again, showing the retry on the console. If the error persists, it will be thrown. + + php artisan db:seed + + INFO Seeding database. + + Database\Seeders\UserSeeder ...................................... RUNNING + ~ Seed normal users ............................................. CONTINUE + ~ Seed non-authorized users ................................. RETRY UNIQUE + ~ Seed non-authorized users ......................................... DONE + Database\Seeders\UserSeeder ................................... 32 ms DONE + +You can set the `retryUnique` property of the `SeedStep` attribute to any number of retries from the default `1`. Alternatively, setting it to `false` or `0` will disable it. + +```php +use Laragear\Populate\Attributes\SeedStep; + +#[SeedStep(retryUnique: false)] +public function bannedUsers +{ + // ... +} +``` + ### Disable transactions Laragear Populate's Seeders wraps each Seed Step into its own transaction using the default database connection. This means that, when a Seed Step fails, all database operations inside that method are rolled back. diff --git a/src/Attributes/SeedStep.php b/src/Attributes/SeedStep.php index 9f0519f..45ef5fd 100644 --- a/src/Attributes/SeedStep.php +++ b/src/Attributes/SeedStep.php @@ -10,8 +10,11 @@ class SeedStep /** * Create a new Seed Step instance. */ - public function __construct(public string $as = '', public ?bool $withoutModelEvents = null) - { + public function __construct( + public string $as = '', + public ?bool $withoutModelEvents = null, + public false|int $retryUnique = 1, + ) { // } } diff --git a/src/Exceptions/SkipSeeding.php b/src/Exceptions/SkipSeeding.php index 128c243..7cd7928 100644 --- a/src/Exceptions/SkipSeeding.php +++ b/src/Exceptions/SkipSeeding.php @@ -4,6 +4,9 @@ use RuntimeException; +/** + * @internal + */ class SkipSeeding extends RuntimeException { // diff --git a/src/Pipes/MayLoadPreviousSeeding.php b/src/Pipes/MayLoadPreviousSeeding.php index 82d8deb..cd3dd46 100644 --- a/src/Pipes/MayLoadPreviousSeeding.php +++ b/src/Pipes/MayLoadPreviousSeeding.php @@ -79,6 +79,6 @@ protected function continueFilePath(Seeding $seeding): string */ protected function outputContinuation(Seeding $seeding): void { - $seeding->command?->line('Continuing from previous incomplete seeding.'); + $seeding->comment('Continuing from previous incomplete seeding.'); } } diff --git a/src/Pipes/MaySkipSeeder.php b/src/Pipes/MaySkipSeeder.php index ca8853a..d1b2b4e 100644 --- a/src/Pipes/MaySkipSeeder.php +++ b/src/Pipes/MaySkipSeeder.php @@ -44,12 +44,10 @@ protected function callBefore(Seeding $seeding): bool */ protected function outputSkipped(Seeding $seeding, SkipSeeding $e): void { - $seeding->command?->outputComponents()->twoColumnDetail( - '~ '.$seeding->seeder::class, 'SKIPPED', - ); + $seeding->twoColumn('~ '.$seeding->seeder::class, 'SKIPPED'); if ($e->getMessage()) { - $seeding->command?->comment(" {$e->getMessage()}"); + $seeding->comment(" {$e->getMessage()}"); } } } diff --git a/src/Pipes/RemoveContinueDataAndFile.php b/src/Pipes/RemoveContinueDataAndFile.php index 67e7899..6af6067 100644 --- a/src/Pipes/RemoveContinueDataAndFile.php +++ b/src/Pipes/RemoveContinueDataAndFile.php @@ -9,6 +9,9 @@ use Laragear\Populate\Populator; use Laragear\Populate\Seeding; +/** + * @internal + */ class RemoveContinueDataAndFile { /** diff --git a/src/Pipes/WrapSeedSteps.php b/src/Pipes/WrapSeedSteps.php index 07b8e85..d451295 100644 --- a/src/Pipes/WrapSeedSteps.php +++ b/src/Pipes/WrapSeedSteps.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Arr; use Illuminate\Support\Str; use Laragear\Populate\Attributes\SeedStep; @@ -41,15 +42,18 @@ public function handle(Seeding $seeding, Closure $next): mixed $seeding->steps->transform( function (ReflectionMethod $method) use ($seeding, $withoutEvents): Closure { return function () use ($seeding, $method, $withoutEvents): void { - [$output, $silent] = $this->parseMethodAttribute($method, $withoutEvents); + $seedStep = $this->parseMethodAttribute($method, $withoutEvents); - if ($this->seedStepAlreadyRan($seeding, $method->name, $output)) { + if ($this->seedStepAlreadyRan($seeding, $method->name, $seedStep)) { return; } $this->parseResult( $this->handleSeedStep( - $seeding, $method, $output, $seeding->parameters[$method->name] ?? [], $silent + $seeding, + $method, + $seedStep, + $seeding->parameters[$method->name] ?? [], ), ); @@ -64,10 +68,10 @@ function (ReflectionMethod $method) use ($seeding, $withoutEvents): Closure { /** * Check if the method already ran. */ - protected function seedStepAlreadyRan(Seeding $seeding, string $method, string $output): bool + protected function seedStepAlreadyRan(Seeding $seeding, string $method, SeedStep $step): bool { if (isset($this->data->continue[$seeding->seeder::class][$method])) { - $seeding->command?->outputComponents()->twoColumnDetail("~ $output", 'CONTINUE'); + $seeding->twoColumn("~ $step->as", 'CONTINUE'); return true; } @@ -96,27 +100,30 @@ protected function parseResult(mixed $result): void protected function handleSeedStep( Seeding $seeding, ReflectionMethod $method, - string $output, + SeedStep $step, array $parameters, - bool $withoutEvents, ): mixed { - { - try { - $result = $this->runSeedStep($seeding, $method, $parameters, $withoutEvents); - } catch (SkipSeeding $e) { - return $this->outputSeedStepSkipped($seeding, $output, $e); - } catch (Throwable $e) { - $seeding->command?->outputComponents()->twoColumnDetail("! $output", 'ERROR'); - - throw $e; - } + try { + $result = $this->runSeedStep($seeding, $method, $parameters, $step->withoutModelEvents); + } catch (SkipSeeding $e) { + return $this->outputSeedStepSkipped($seeding, $step, $e); + } catch (UniqueConstraintViolationException $e) { + if ($seeding->seeder->useTransactions && $step->retryUnique > 0) { + $seeding->twoColumn("~ $step->as", 'RETRY UNIQUE'); - $seeding->command?->outputComponents()->twoColumnDetail( - "~ $output", 'DONE', - ); + --$step->retryUnique; + + return $this->handleSeedStep($seeding, $method, $step, $seeding->parameters[$method->name] ?? []); + } - return $result; + $this->throwStepError($seeding, $step, $e); + } catch (Throwable $e) { + $this->throwStepError($seeding, $step, $e); } + + $seeding->twoColumn("~ $step->as", 'DONE'); + + return $result; } /** @@ -135,12 +142,12 @@ protected function runSeedStep(Seeding $seeding, ReflectionMethod $method, array if ($silent) { if (method_exists($seeding->seeder, 'withoutModelEvents')) { return $seeding->seeder->withoutModelEvents( - fn(): mixed => $seeding->container->call([$seeding->seeder, $method->name], $parameters) + fn(): mixed => $seeding->container->call([$seeding->seeder, $method->name], $parameters), )(); } return Model::withoutEvents( - fn(): mixed => $seeding->container->call([$seeding->seeder, $method->name], $parameters) + fn(): mixed => $seeding->container->call([$seeding->seeder, $method->name], $parameters), ); } @@ -149,35 +156,40 @@ protected function runSeedStep(Seeding $seeding, ReflectionMethod $method, array /** * Parse the Method Attribute to retrieve the output name and events configuration. - * - * @return array{string, bool} */ - protected function parseMethodAttribute(ReflectionMethod $method, bool $withoutModelEvents): array + protected function parseMethodAttribute(ReflectionMethod $method, bool $withoutModelEvents): SeedStep { - if ($attribute = Arr::first($method->getAttributes(SeedStep::class))?->newInstance()) { - return [ - $attribute->as ?: Str::ucfirst(Str::snake($method->name, ' ')), - $attribute->withoutModelEvents ?? $withoutModelEvents - ]; - } + /** @var \Laragear\Populate\Attributes\SeedStep $attribute */ + $attribute = Arr::first($method->getAttributes(SeedStep::class))?->newInstance() + ?? new SeedStep(); - return [Str::ucfirst(Str::snake($method->name, ' ')), $withoutModelEvents]; + $attribute->as = $attribute->as ?: Str::ucfirst(Str::snake($method->name, ' ')); + $attribute->withoutModelEvents ??= $withoutModelEvents; + + return $attribute; } /** * Outputs the Seed Step that was skipped to the console. */ - protected function outputSeedStepSkipped(Seeding $seeding, string $output, SkipSeeding $e): false + protected function outputSeedStepSkipped(Seeding $seeding, SeedStep $seedStep, SkipSeeding $e): false { - $seeding->command?->outputComponents()->twoColumnDetail( - "~ $output", 'SKIPPED', - ); + $seeding->twoColumn("~ $seedStep->as", 'SKIPPED'); if ($e->getMessage()) { - $seeding->command?->comment(" {$e->getMessage()}"); + $seeding->comment(" {$e->getMessage()}"); } return false; } + /** + * Throws an exception and output in the console when the Seed Step errors. + */ + protected function throwStepError(Seeding $seeding, SeedStep $step, Throwable $e): never + { + $seeding->twoColumn("! $step->as", 'ERROR'); + + throw $e; + } } diff --git a/src/Seeding.php b/src/Seeding.php index c6d7551..4189e5f 100644 --- a/src/Seeding.php +++ b/src/Seeding.php @@ -36,4 +36,20 @@ public function classFileName(): string { return Str::replace('\\', '_', $this->class); } + + /** + * Print two columns in the console. + */ + public function twoColumn(string $first, ?string $second = null): void + { + $this->command?->outputComponents()->twoColumnDetail($first, $second); + } + + /** + * Print a comment in the console. + */ + public function comment(string $string): void + { + $this->command?->comment($string); + } } diff --git a/tests/Pipelines/MayLoadPreviousSeedingTest.php b/tests/Pipelines/MayLoadPreviousSeedingTest.php index 244c3e2..1758880 100644 --- a/tests/Pipelines/MayLoadPreviousSeedingTest.php +++ b/tests/Pipelines/MayLoadPreviousSeedingTest.php @@ -60,7 +60,7 @@ public function test_loads_data_if_command_has_option(): void $command = $this->mock(Command::class, function (MockInterface $mock) { $mock->expects('option')->with('continue')->andReturnTrue(); - $mock->expects('line')->with('Continuing from previous incomplete seeding.'); + $mock->expects('comment')->with('Continuing from previous incomplete seeding.'); }); $passable = new Seeding($this->app, $command, new EmptySeeder(), [], class: 'test'); diff --git a/tests/Pipelines/WrapSeedStepsTest.php b/tests/Pipelines/WrapSeedStepsTest.php index f621cea..28ed948 100644 --- a/tests/Pipelines/WrapSeedStepsTest.php +++ b/tests/Pipelines/WrapSeedStepsTest.php @@ -2,18 +2,18 @@ namespace Tests\Pipelines; -use Closure; use Exception; use Illuminate\Console\View\Components\Factory; use Illuminate\Database\Console\Seeds\SeedCommand; use Illuminate\Database\Eloquent\Factories\Factory as EloquentFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Event; +use Laragear\Populate\Attributes\SeedStep; use Laragear\Populate\ContinueData; use Laragear\Populate\Pipes\WrapSeedSteps; +use Laragear\Populate\Seeder; use Laragear\Populate\Seeding; -use Mockery; use Mockery\MockInterface; use ReflectionMethod; use Tests\Fixtures\NamedSeedStepSeeder; @@ -48,16 +48,16 @@ public function test_transforms_reflection_method_into_seed_step_call_closure(): ->handle($passable, function (Seeding $seeding) { static::assertIsCallable($seeding->steps[0]); - $seeding->steps->each(fn ($step) => $step()); + $seeding->steps->each(fn($step) => $step()); - static::assertContains(VariedSeeder::class . '::seed', $seeding->seeder->ran); + static::assertContains(VariedSeeder::class.'::seed', $seeding->seeder->ran); }); } public function test_seed_step_skips_if_already_ran(): void { $this->app->instance(ContinueData::class, new ContinueData([ - VariedSeeder::class => ['seed' => true] + VariedSeeder::class => ['seed' => true], ])); $seeder = new VariedSeeder(); @@ -82,19 +82,19 @@ public function test_seed_step_skips_if_already_ran(): void ->handle($passable, function (Seeding $seeding) { static::assertCount(3, $seeding->steps); - $seeding->steps->each(fn ($step) => $step()); + $seeding->steps->each(fn($step) => $step()); static::assertCount(2, $seeding->seeder->ran); - static::assertNotContains(VariedSeeder::class . '::seed', $seeding->seeder->ran);; - static::assertContains(VariedSeeder::class . '::seedSecond', $seeding->seeder->ran); - static::assertContains(VariedSeeder::class . '::withAttribute', $seeding->seeder->ran); + static::assertNotContains(VariedSeeder::class.'::seed', $seeding->seeder->ran); + static::assertContains(VariedSeeder::class.'::seedSecond', $seeding->seeder->ran); + static::assertContains(VariedSeeder::class.'::withAttribute', $seeding->seeder->ran); }); } public function test_seed_step_skips_outputs_custom_name(): void { $this->app->instance(ContinueData::class, new ContinueData([ - NamedSeedStepSeeder::class => ['withAttribute' => true] + NamedSeedStepSeeder::class => ['withAttribute' => true], ])); $seeder = new NamedSeedStepSeeder(); @@ -119,12 +119,12 @@ public function test_seed_step_skips_outputs_custom_name(): void ->handle($passable, function (Seeding $seeding) { static::assertCount(3, $seeding->steps); - $seeding->steps->each(fn ($step) => $step()); + $seeding->steps->each(fn($step) => $step()); static::assertCount(2, $seeding->seeder->ran); - static::assertContains(NamedSeedStepSeeder::class . '::seed', $seeding->seeder->ran);; - static::assertContains(NamedSeedStepSeeder::class . '::seedSecond', $seeding->seeder->ran); - static::assertNotContains(NamedSeedStepSeeder::class . '::withAttribute', $seeding->seeder->ran); + static::assertContains(NamedSeedStepSeeder::class.'::seed', $seeding->seeder->ran); + static::assertContains(NamedSeedStepSeeder::class.'::seedSecond', $seeding->seeder->ran); + static::assertNotContains(NamedSeedStepSeeder::class.'::withAttribute', $seeding->seeder->ran); }); } @@ -142,12 +142,12 @@ public function test_runs_seed_step_without_model_events_by_seeder(): void ->handle($passable, function (Seeding $seeding) { static::assertCount(3, $seeding->steps); - $seeding->steps->each(fn ($step) => $step()); + $seeding->steps->each(fn($step) => $step()); static::assertCount(3, $seeding->seeder->ran); - static::assertContains(VariedSeederWithoutModelEvents::class . '::seed', $seeding->seeder->ran);; - static::assertContains(VariedSeederWithoutModelEvents::class . '::seedSecond', $seeding->seeder->ran); - static::assertContains(VariedSeederWithoutModelEvents::class . '::withAttribute', $seeding->seeder->ran); + static::assertContains(VariedSeederWithoutModelEvents::class.'::seed', $seeding->seeder->ran); + static::assertContains(VariedSeederWithoutModelEvents::class.'::seedSecond', $seeding->seeder->ran); + static::assertContains(VariedSeederWithoutModelEvents::class.'::withAttribute', $seeding->seeder->ran); }); } @@ -201,7 +201,7 @@ public function test_skips_seed_step(): void ->handle($passable, function (Seeding $seeding) { static::assertCount(3, $seeding->steps); - $seeding->steps->each(fn ($step) => $step()); + $seeding->steps->each(fn($step) => $step()); static::assertCount(1, $seeding->seeder->ran); static::assertContains(VariedSeederSkipsSeedStep::class.'::seed', $seeding->seeder->ran); @@ -234,11 +234,11 @@ public function test_seed_step_error_is_added_to_continue(): void try { $pipe->handle($passable, function (Seeding $seeding) { - $seeding->steps->each(fn ($step) => $step()); + $seeding->steps->each(fn($step) => $step()); }); } catch (Throwable $e) { static::assertCount(1, $seeder->ran); - static::assertContains(VariedSeederWithError::class . '::seedFirst', $seeder->ran); + static::assertContains(VariedSeederWithError::class.'::seedFirst', $seeder->ran); throw $e; } @@ -263,8 +263,213 @@ public function test_parses_results(): void $this->app->make(WrapSeedSteps::class) ->handle($passable, function (Seeding $seeding) { - $seeding->steps->each(fn ($step) => $step()); + $seeding->steps->each(fn($step) => $step()); }); } + + public function test_retries_unique_constraint_validation(): void + { + $seeder = new class extends Seeder { + public $ran = 0; + + public function seed() + { + $this->ran++; + + throw new UniqueConstraintViolationException('test', 'sql', [], new Exception()); + } + }; + + $command = $this->mock(SeedCommand::class, function (MockInterface $mock) { + $factory = $this->mock(Factory::class, function (MockInterface $mock) { + $mock->expects('twoColumnDetail')->with('~ Seed', 'RETRY UNIQUE'); + $mock->expects('twoColumnDetail')->with('! Seed', 'ERROR'); + }); + + $mock->expects('outputComponents')->twice()->andReturn($factory); + }); + + $passable = new Seeding($this->app, $command, $seeder, [], new Collection([ + new ReflectionMethod($seeder, 'seed'), + ])); + + $pipe = $this->app->make(WrapSeedSteps::class); + + $this->expectException(UniqueConstraintViolationException::class); + + try { + $pipe->handle($passable, function (Seeding $seeding) { + $seeding->steps->each(fn($step) => $step()); + }); + } catch (UniqueConstraintViolationException $e) { + static::assertSame(2, $seeder->ran); + + throw $e; + } + } + + public function test_retries_unique_constraint_validation_continiues_seeding(): void + { + $seeder = new class extends Seeder { + public $ran = 0; + + public function seed() + { + $this->ran++; + + if ($this->ran < 2) { + throw new UniqueConstraintViolationException('test', 'sql', [], new Exception()); + } + } + }; + + $command = $this->mock(SeedCommand::class, function (MockInterface $mock) { + $factory = $this->mock(Factory::class, function (MockInterface $mock) { + $mock->expects('twoColumnDetail')->with('~ Seed', 'RETRY UNIQUE'); + $mock->expects('twoColumnDetail')->with('~ Seed', 'DONE'); + $mock->expects('twoColumnDetail')->with('! Seed', 'ERROR')->never(); + }); + + $mock->expects('outputComponents')->times(2)->andReturn($factory); + }); + + $passable = new Seeding($this->app, $command, $seeder, [], new Collection([ + new ReflectionMethod($seeder, 'seed'), + ])); + + $this->app->make(WrapSeedSteps::class) + ->handle($passable, function (Seeding $seeding) { + $seeding->steps->each(fn($step) => $step()); + }); + + static::assertSame(2, $seeder->ran); + } + + public function test_doesnt_retries_unique_constraint_validation_when_transactions_disabled(): void + { + $seeder = new class extends Seeder { + public $ran = 0; + + public bool $useTransactions = false; + + public function seed() + { + $this->ran++; + + throw new UniqueConstraintViolationException('test', 'sql', [], new Exception()); + } + }; + + $command = $this->mock(SeedCommand::class, function (MockInterface $mock) { + $factory = $this->mock(Factory::class, function (MockInterface $mock) { + $mock->expects('twoColumnDetail')->with('~ Seed', 'RETRY UNIQUE')->never(); + $mock->expects('twoColumnDetail')->with('! Seed', 'ERROR'); + }); + + $mock->expects('outputComponents')->andReturn($factory); + }); + + $passable = new Seeding($this->app, $command, $seeder, [], new Collection([ + new ReflectionMethod($seeder, 'seed'), + ])); + + $pipe = $this->app->make(WrapSeedSteps::class); + + $this->expectException(UniqueConstraintViolationException::class); + + try { + $pipe->handle($passable, function (Seeding $seeding) { + $seeding->steps->each(fn($step) => $step()); + }); + } catch (UniqueConstraintViolationException $e) { + static::assertSame(1, $seeder->ran); + + throw $e; + } + } + + public function test_retries_unique_constraint_validation_a_number_of_tries(): void + { + $seeder = new class extends Seeder { + public $ran = 0; + + #[SeedStep(retryUnique: 3)] + public function seed() + { + $this->ran++; + + throw new UniqueConstraintViolationException('test', 'sql', [], new Exception()); + } + }; + + $command = $this->mock(SeedCommand::class, function (MockInterface $mock) { + $factory = $this->mock(Factory::class, function (MockInterface $mock) { + $mock->expects('twoColumnDetail')->times(3)->with('~ Seed', 'RETRY UNIQUE'); + $mock->expects('twoColumnDetail')->with('! Seed', 'ERROR'); + }); + + $mock->expects('outputComponents')->times(4)->andReturn($factory); + }); + + $passable = new Seeding($this->app, $command, $seeder, [], new Collection([ + new ReflectionMethod($seeder, 'seed'), + ])); + + $pipe = $this->app->make(WrapSeedSteps::class); + + $this->expectException(UniqueConstraintViolationException::class); + + try { + $pipe->handle($passable, function (Seeding $seeding) { + $seeding->steps->each(fn($step) => $step()); + }); + } catch (UniqueConstraintViolationException $e) { + static::assertSame(4, $seeder->ran); + + throw $e; + } + } + + public function test_doesnt_retry_when_retry_unique_is_false(): void + { + $seeder = new class extends Seeder { + public $ran = 0; + + #[SeedStep(retryUnique: false)] + public function seed() + { + $this->ran++; + + throw new UniqueConstraintViolationException('test', 'sql', [], new Exception()); + } + }; + + $command = $this->mock(SeedCommand::class, function (MockInterface $mock) { + $factory = $this->mock(Factory::class, function (MockInterface $mock) { + $mock->expects('twoColumnDetail')->with('~ Seed', 'RETRY UNIQUE')->never(); + $mock->expects('twoColumnDetail')->with('! Seed', 'ERROR'); + }); + + $mock->expects('outputComponents')->andReturn($factory); + }); + + $passable = new Seeding($this->app, $command, $seeder, [], new Collection([ + new ReflectionMethod($seeder, 'seed'), + ])); + + $pipe = $this->app->make(WrapSeedSteps::class); + + $this->expectException(UniqueConstraintViolationException::class); + + try { + $pipe->handle($passable, function (Seeding $seeding) { + $seeding->steps->each(fn($step) => $step()); + }); + } catch (UniqueConstraintViolationException $e) { + static::assertSame(1, $seeder->ran); + + throw $e; + } + } }