diff --git a/src/Seeders/Seeder.php b/src/Seeders/Seeder.php new file mode 100644 index 0000000..040de10 --- /dev/null +++ b/src/Seeders/Seeder.php @@ -0,0 +1,84 @@ +insert([ + * 'name' => 'Admin', + * 'email' => 'admin@example.com', + * ]); + * + * // Optionally call other seeders + * $this->call(RolesSeeder::class); + * } + * } + */ +abstract class Seeder +{ + /** + * The database connection name to run this seeder on. + * Null means the current default connection. + * + * @var string|null + */ + public ?string $connection = null; + + /** + * The SeederRunner currently executing this seeder. + * Injected by the runner so $this->call() can delegate back to it. + * + * @var SeederRunner|null + */ + protected ?SeederRunner $runner = null; + + /** + * Apply the seeder — insert default or test data. + * + * @return void + */ + abstract public function run(): void; + + /** + * Inject the runner that is executing this seeder. + * + * @param SeederRunner $runner + * @return void + */ + public function setRunner(SeederRunner $runner): void + { + $this->runner = $runner; + } + + /** + * Run one or more other seeders from inside this seeder. + * + * @param string|array $seeders Class name(s) of seeders to run + * @return array + */ + public function call(string|array $seeders): array + { + $seeders = is_array($seeders) ? $seeders : [$seeders]; + $results = []; + + foreach ($seeders as $class) { + $results[] = $this->runner !== null + ? $this->runner->runClass($class) + : SeederResult::fail($class, 0.0, 'No runner attached to seeder.'); + } + + return $results; + } +} diff --git a/src/Seeders/SeederResult.php b/src/Seeders/SeederResult.php new file mode 100644 index 0000000..a2cd114 --- /dev/null +++ b/src/Seeders/SeederResult.php @@ -0,0 +1,58 @@ +success ? 'OK' : 'FAILED'; + $time = number_format($this->timeMs, 2); + $line = "[{$status}] seed {$this->name} ({$time} ms)"; + + if (! $this->success && $this->error !== '') { + $line .= " — {$this->error}"; + } + + return $line; + } +} diff --git a/src/Seeders/SeederRunner.php b/src/Seeders/SeederRunner.php new file mode 100644 index 0000000..f9a13b0 --- /dev/null +++ b/src/Seeders/SeederRunner.php @@ -0,0 +1,304 @@ +runAll(); + * + * // Run a single seeder by class name + * $result = $runner->runClass(UsersSeeder::class); + * + * // Run a single seeder by file name (without .php) + * $result = $runner->runFile('UsersSeeder'); + * + * // List discovered seeder files (without .php) + * $files = $runner->getSeederFiles(); + */ +class SeederRunner +{ + /** + * The directory where seeder files live. + * + * @var string + */ + protected string $path; + + /** + * Optional default connection name used when a seeder doesn't set one. + * + * @var string|null + */ + protected ?string $connection; + + /** + * Whether to wrap each seeder in a transaction. + * Defaults to true; can be disabled for engines that don't support DDL + * inside transactions or when the caller wants partial inserts to persist. + * + * @var bool + */ + protected bool $useTransaction = true; + + /** + * @param string $path Path to the seeders directory + * @param string|null $connection Named connection (null = default) + */ + public function __construct(string $path, ?string $connection = null) + { + $this->path = rtrim($path, '/\\'); + $this->connection = $connection; + } + + // ----------------------------------------------------------------------- + // Configuration + // ----------------------------------------------------------------------- + + /** + * Enable or disable wrapping each seeder in a transaction. + * + * @param bool $enable + * @return self + */ + public function useTransaction(bool $enable): self + { + $this->useTransaction = $enable; + return $this; + } + + // ----------------------------------------------------------------------- + // Run + // ----------------------------------------------------------------------- + + /** + * Run every seeder file found in the seeders directory, sorted by file name. + * + * @return array + */ + public function runAll(): array + { + $files = $this->getSeederFiles(); + $results = []; + + foreach ($files as $name) { + $result = $this->runFile($name); + $results[] = $result; + + if (! $result->success) { + break; // Stop on first failure + } + } + + return $results; + } + + /** + * Run a single seeder by file name (without the .php extension). + * + * @param string $name + * @return SeederResult + */ + public function runFile(string $name): SeederResult + { + $start = hrtime(true); + + try { + $class = $this->resolveFromFile($name); + return $this->execute($class, $start); + } catch (Throwable $e) { + return SeederResult::fail($name, $this->elapsedMs($start), $e->getMessage()); + } + } + + /** + * Run a single seeder by its class name. + * + * If the class is already autoloadable (real namespace, composer-loaded), + * it is instantiated directly. Otherwise the runner looks for a file + * named after the class (with or without namespace prefix stripped) inside + * the seeders directory and requires it. + * + * @param string $class + * @return SeederResult + */ + public function runClass(string $class): SeederResult + { + $start = hrtime(true); + + try { + if (! class_exists($class)) { + // Try to load by file name in the seeders directory. + // Strip namespace if present: "App\Seeders\Foo" -> "Foo". + $shortName = ($pos = strrpos($class, '\\')) !== false + ? substr($class, $pos + 1) + : $class; + + $path = $this->path . DIRECTORY_SEPARATOR . $shortName . '.php'; + + if (! file_exists($path)) { + throw new \RuntimeException("Seeder class [{$class}] not found."); + } + + require_once $path; + + // Resolve either the original FQCN or the bare class name. + if (! class_exists($class) && class_exists($shortName)) { + $class = $shortName; + } + + if (! class_exists($class)) { + throw new \RuntimeException("Seeder class [{$class}] not found."); + } + } + + return $this->execute($class, $start); + } catch (Throwable $e) { + return SeederResult::fail($class, $this->elapsedMs($start), $e->getMessage()); + } + } + + // ----------------------------------------------------------------------- + // File discovery + // ----------------------------------------------------------------------- + + /** + * Get all seeder file names (without .php) from the seeders directory, + * sorted alphabetically. + * + * @return array + */ + public function getSeederFiles(): array + { + if (! is_dir($this->path)) { + return []; + } + + $files = glob($this->path . DIRECTORY_SEPARATOR . '*.php'); + + if ($files === false || empty($files)) { + return []; + } + + $names = array_map( + fn(string $f) => pathinfo($f, PATHINFO_FILENAME), + $files, + ); + + sort($names); + + return $names; + } + + /** + * Get the seeders directory path. + * + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + /** + * Require a seeder file and return its fully qualified class name. + * + * The class name is derived from the file name itself (we assume the + * file is named after the class, just like Laravel-style seeders). + * + * @param string $name File name without .php + * @return string Fully qualified class name + * + * @throws \RuntimeException If the file or class is not found + */ + protected function resolveFromFile(string $name): string + { + $path = $this->path . DIRECTORY_SEPARATOR . $name . '.php'; + + if (! file_exists($path)) { + throw new \RuntimeException("Seeder file not found: {$path}"); + } + + require_once $path; + + // Try the bare name first, then a couple of common namespaces. + $candidates = [ + $name, + "App\\Seeders\\{$name}", + "Database\\Seeders\\{$name}", + ]; + + foreach ($candidates as $candidate) { + if (class_exists($candidate)) { + return $candidate; + } + } + + throw new \RuntimeException( + "Seeder class for file [{$name}] not found. " . + "Tried: " . implode(', ', $candidates) + ); + } + + /** + * Instantiate and execute a seeder class, returning a result. + * + * @param string $class + * @param int $startNs hrtime(true) value captured before this call + * @return SeederResult + */ + protected function execute(string $class, int $startNs): SeederResult + { + /** @var Seeder $seeder */ + $seeder = new $class(); + + if (! $seeder instanceof Seeder) { + throw new \RuntimeException( + "Class [{$class}] does not extend " . Seeder::class + ); + } + + $seeder->setRunner($this); + + $connection = $seeder->connection ?? $this->connection; + $conn = DB::connection($connection); + + if ($this->useTransaction) { + $conn->transaction(function () use ($seeder): void { + $seeder->run(); + }); + } else { + $seeder->run(); + } + + return SeederResult::ok($class, $this->elapsedMs($startNs)); + } + + /** + * Calculate elapsed milliseconds from an hrtime(true) start value. + * + * @param int $startNs + * @return float + */ + private function elapsedMs(int $startNs): float + { + return (hrtime(true) - $startNs) / 1_000_000; + } +} diff --git a/tests/Integration/SeederRunnerTest.php b/tests/Integration/SeederRunnerTest.php new file mode 100644 index 0000000..f3923a2 --- /dev/null +++ b/tests/Integration/SeederRunnerTest.php @@ -0,0 +1,414 @@ +call() chains through the runner + * - Transaction wrapping rolls back on error + * - useTransaction(false) disables the wrapper + * - Errors are captured into SeederResult, not thrown + */ +class SeederRunnerTest extends IntegrationTestCase +{ + /** + * Temporary directory where each test writes its seeder files. + * + * @var string + */ + private string $tmpDir; + + // ----------------------------------------------------------------------- + // Schema + // ----------------------------------------------------------------------- + + protected static function createSchema(): void + { + Schema::create('seed_users', function (Blueprint $t) { + $t->id(); + $t->string('name'); + }); + + Schema::create('seed_roles', function (Blueprint $t) { + $t->id(); + $t->string('name'); + }); + } + + protected static function dropSchema(): void + { + Schema::dropIfExists('seed_users'); + Schema::dropIfExists('seed_roles'); + } + + // ----------------------------------------------------------------------- + // Per-test setup / teardown + // ----------------------------------------------------------------------- + + protected function setUp(): void + { + parent::setUp(); + + $this->tmpDir = sys_get_temp_dir() . '/foxdb_seeders_' . uniqid('', true); + mkdir($this->tmpDir); + + static::truncate('seed_users'); + static::truncate('seed_roles'); + } + + protected function tearDown(): void + { + if (is_dir($this->tmpDir)) { + foreach (glob($this->tmpDir . '/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($this->tmpDir); + } + + parent::tearDown(); + } + + // ----------------------------------------------------------------------- + // File discovery + // ----------------------------------------------------------------------- + + public function test_getSeederFiles_returns_empty_for_missing_dir(): void + { + $runner = new SeederRunner($this->tmpDir . '/does-not-exist'); + $this->assertSame([], $runner->getSeederFiles()); + } + + public function test_getSeederFiles_returns_empty_when_dir_has_no_php_files(): void + { + file_put_contents($this->tmpDir . '/readme.txt', 'not a seeder'); + + $runner = new SeederRunner($this->tmpDir); + $this->assertSame([], $runner->getSeederFiles()); + } + + public function test_getSeederFiles_returns_php_files_sorted(): void + { + file_put_contents($this->tmpDir . '/ZSeeder.php', 'tmpDir . '/ASeeder.php', 'tmpDir . '/MSeeder.php', 'tmpDir . '/notes.md', 'ignored'); + + $runner = new SeederRunner($this->tmpDir); + $this->assertSame(['ASeeder', 'MSeeder', 'ZSeeder'], $runner->getSeederFiles()); + } + + public function test_getPath_returns_normalized_path(): void + { + $runner = new SeederRunner($this->tmpDir . '/'); + // Trailing slashes are stripped. + $this->assertSame(rtrim($this->tmpDir, '/\\'), $runner->getPath()); + } + + // ----------------------------------------------------------------------- + // runFile / runAll / runClass + // ----------------------------------------------------------------------- + + public function test_runFile_executes_seeder_and_persists_inserts(): void + { + $this->writeSeeder('UsersSeeder_File', <<<'PHP' + insert(['name' => 'Ali']); + } + } + PHP); + + $runner = new SeederRunner($this->tmpDir); + $result = $runner->runFile('UsersSeeder_File'); + + $this->assertTrue($result->success, $result->error); + $this->assertSame('UsersSeeder_File', $result->name); + $this->assertGreaterThan(0.0, $result->timeMs); + $this->assertSame('', $result->error); + $this->assertSame(1, (int) DB::table('seed_users')->count()); + } + + public function test_runFile_returns_failed_result_for_missing_file(): void + { + $runner = new SeederRunner($this->tmpDir); + $result = $runner->runFile('NoSuchSeeder'); + + $this->assertFalse($result->success); + $this->assertSame('NoSuchSeeder', $result->name); + $this->assertStringContainsString('not found', $result->error); + } + + public function test_runFile_returns_failed_result_when_class_missing(): void + { + // PHP file exists but defines a class with a different name. + file_put_contents( + $this->tmpDir . '/MismatchedSeeder.php', + "tmpDir); + $result = $runner->runFile('MismatchedSeeder'); + + $this->assertFalse($result->success); + $this->assertStringContainsString('not found', $result->error); + } + + public function test_runFile_returns_failed_result_when_class_does_not_extend_Seeder(): void + { + file_put_contents( + $this->tmpDir . '/NotASeeder.php', + "tmpDir); + $result = $runner->runFile('NotASeeder'); + + $this->assertFalse($result->success); + $this->assertStringContainsString('does not extend', $result->error); + } + + public function test_runAll_executes_every_file_in_alphabetical_order(): void + { + $this->writeSeeder('A_RolesSeeder', <<<'PHP' + insert(['name' => 'admin']); + } + } + PHP); + + $this->writeSeeder('B_UsersSeeder', <<<'PHP' + insert(['name' => 'Ali']); + } + } + PHP); + + $runner = new SeederRunner($this->tmpDir); + $results = $runner->runAll(); + + $this->assertCount(2, $results); + $this->assertSame('A_RolesSeeder', $results[0]->name); + $this->assertSame('B_UsersSeeder', $results[1]->name); + $this->assertTrue($results[0]->success); + $this->assertTrue($results[1]->success); + + $this->assertSame(1, (int) DB::table('seed_roles')->count()); + $this->assertSame(1, (int) DB::table('seed_users')->count()); + } + + public function test_runAll_stops_on_first_failure(): void + { + $this->writeSeeder('A_OkSeeder', <<<'PHP' + insert(['name' => 'first']); + } + } + PHP); + + $this->writeSeeder('B_BrokenSeeder', <<<'PHP' + writeSeeder('C_NeverRunsSeeder', <<<'PHP' + insert(['name' => 'never']); + } + } + PHP); + + $runner = new SeederRunner($this->tmpDir); + $results = $runner->runAll(); + + $this->assertCount(2, $results, 'runAll must stop after the first failure'); + $this->assertTrue($results[0]->success); + $this->assertFalse($results[1]->success); + $this->assertStringContainsString('intentional', $results[1]->error); + + $rows = DB::table('seed_users')->get()->all(); + $this->assertCount(1, $rows); + $this->assertSame('first', $rows[0]->name); + } + + public function test_runClass_executes_by_fqcn_without_a_file(): void + { + // Define a seeder class directly, no file needed. + eval(<<<'PHP' + namespace Foxdb\Tests\Integration\Inline { + use Foxdb\DB; + use Foxdb\Seeders\Seeder; + class InlineSeeder extends Seeder { + public function run(): void { + DB::table('seed_users')->insert(['name' => 'inline']); + } + } + } + PHP); + + $runner = new SeederRunner($this->tmpDir); + $result = $runner->runClass('Foxdb\\Tests\\Integration\\Inline\\InlineSeeder'); + + $this->assertTrue($result->success, $result->error); + $this->assertSame(1, (int) DB::table('seed_users')->count()); + } + + public function test_runClass_returns_failed_result_for_missing_class(): void + { + $runner = new SeederRunner($this->tmpDir); + $result = $runner->runClass('NoSuchClassAnywhere'); + + $this->assertFalse($result->success); + $this->assertStringContainsString('not found', $result->error); + } + + // ----------------------------------------------------------------------- + // $this->call() chaining + // ----------------------------------------------------------------------- + + public function test_seeder_can_call_other_seeders_via_runner(): void + { + $this->writeSeeder('Chain_RolesSeeder', <<<'PHP' + insert(['name' => 'editor']); + } + } + PHP); + + $this->writeSeeder('Chain_MasterSeeder', <<<'PHP' + insert(['name' => 'root']); + $this->call('Chain_RolesSeeder'); + } + } + PHP); + + $runner = new SeederRunner($this->tmpDir); + // Run only the master; it should chain to RolesSeeder. + $result = $runner->runFile('Chain_MasterSeeder'); + + $this->assertTrue($result->success, $result->error); + $this->assertSame(1, (int) DB::table('seed_users')->count()); + $this->assertSame(1, (int) DB::table('seed_roles')->count()); + } + + // ----------------------------------------------------------------------- + // Transaction wrapping + // ----------------------------------------------------------------------- + + public function test_transaction_wrapping_rolls_back_on_error(): void + { + // Skip on SQLite memory if it cannot handle this scenario reliably. + $this->writeSeeder('Tx_HalfwaySeeder', <<<'PHP' + insert(['name' => 'inserted-before-fail']); + throw new \RuntimeException('boom'); + } + } + PHP); + + $runner = new SeederRunner($this->tmpDir); + $result = $runner->runFile('Tx_HalfwaySeeder'); + + $this->assertFalse($result->success); + // The insert must be rolled back by the surrounding transaction. + $this->assertSame( + 0, + (int) DB::table('seed_users')->count(), + 'Inserts before the exception should have been rolled back' + ); + } + + public function test_useTransaction_false_keeps_inserts_when_error_happens(): void + { + $this->writeSeeder('NoTx_HalfwaySeeder', <<<'PHP' + insert(['name' => 'kept']); + throw new \RuntimeException('boom'); + } + } + PHP); + + $runner = (new SeederRunner($this->tmpDir))->useTransaction(false); + $result = $runner->runFile('NoTx_HalfwaySeeder'); + + $this->assertFalse($result->success); + // Without a transaction, the insert before the throw should persist. + $this->assertSame(1, (int) DB::table('seed_users')->count()); + } + + public function test_useTransaction_returns_self_for_chaining(): void + { + $runner = new SeederRunner($this->tmpDir); + $this->assertSame($runner, $runner->useTransaction(false)); + $this->assertSame($runner, $runner->useTransaction(true)); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** + * Write a seeder file into the per-test temp directory. + * + * The contents may use leading indentation (heredoc-friendly); it is + * trimmed before writing so the PHP open tag ends up at column 0. + */ + private function writeSeeder(string $name, string $contents): void + { + $normalized = ltrim(preg_replace('/^[ \t]+/m', '', $contents) ?? $contents); + file_put_contents($this->tmpDir . '/' . $name . '.php', $normalized); + } +} diff --git a/tests/Unit/SeederResultTest.php b/tests/Unit/SeederResultTest.php new file mode 100644 index 0000000..dc1607f --- /dev/null +++ b/tests/Unit/SeederResultTest.php @@ -0,0 +1,82 @@ +assertSame('UsersSeeder', $r->name); + $this->assertTrue($r->success); + $this->assertSame(12.34, $r->timeMs); + $this->assertSame('', $r->error); + } + + public function test_ok_helper_creates_successful_result(): void + { + $r = SeederResult::ok('UsersSeeder', 5.0); + + $this->assertTrue($r->success); + $this->assertSame('UsersSeeder', $r->name); + $this->assertSame(5.0, $r->timeMs); + $this->assertSame('', $r->error); + } + + public function test_fail_helper_creates_failed_result(): void + { + $r = SeederResult::fail('UsersSeeder', 3.5, 'boom'); + + $this->assertFalse($r->success); + $this->assertSame('UsersSeeder', $r->name); + $this->assertSame(3.5, $r->timeMs); + $this->assertSame('boom', $r->error); + } + + public function test_toString_shows_ok_for_success(): void + { + $r = SeederResult::ok('UsersSeeder', 1.234); + $s = $r->toString(); + + $this->assertStringContainsString('[OK]', $s); + $this->assertStringContainsString('seed UsersSeeder', $s); + $this->assertStringContainsString('1.23 ms', $s); + $this->assertStringNotContainsString('—', $s); + } + + public function test_toString_shows_failed_with_error(): void + { + $r = SeederResult::fail('UsersSeeder', 2.0, 'connection lost'); + $s = $r->toString(); + + $this->assertStringContainsString('[FAILED]', $s); + $this->assertStringContainsString('seed UsersSeeder', $s); + $this->assertStringContainsString('2.00 ms', $s); + $this->assertStringContainsString('connection lost', $s); + } + + public function test_toString_omits_dash_when_error_is_empty(): void + { + // A failed result with no error message should not show a trailing " — ". + $r = new SeederResult('UsersSeeder', false, 1.0, ''); + $this->assertStringNotContainsString('—', $r->toString()); + } + + public function test_properties_are_readonly(): void + { + $r = SeederResult::ok('UsersSeeder', 1.0); + + $this->expectException(\Error::class); + // @phpstan-ignore-next-line — intentional violation for the test + $r->name = 'Other'; + } +} diff --git a/tests/Unit/SeederTest.php b/tests/Unit/SeederTest.php new file mode 100644 index 0000000..b910e60 --- /dev/null +++ b/tests/Unit/SeederTest.php @@ -0,0 +1,113 @@ +ran = true; + } +} + +class SeederTest_CallingSeeder extends Seeder +{ + public array $calls = []; + + public function run(): void + { + // not used; tests invoke ->call() directly + } + + public function doCall(string|array $what): array + { + return $this->call($what); + } +} + +/** + * SeederTest — verifies the abstract Seeder's $this->call() helper and + * runner injection without touching a real database. + */ +class SeederTest extends TestCase +{ + public function test_setRunner_attaches_runner_used_by_call(): void + { + $seeder = new SeederTest_CallingSeeder(); + + // Build a real SeederRunner pointing at an empty temp dir. + // We mock by overriding runClass via a stub subclass: + $runner = new class('/tmp') extends SeederRunner { + public array $called = []; + public function runClass(string $class): SeederResult + { + $this->called[] = $class; + return SeederResult::ok($class, 0.0); + } + }; + + $seeder->setRunner($runner); + $results = $seeder->doCall('SomeOtherSeeder'); + + $this->assertCount(1, $results); + $this->assertTrue($results[0]->success); + $this->assertSame('SomeOtherSeeder', $results[0]->name); + $this->assertSame(['SomeOtherSeeder'], $runner->called); + } + + public function test_call_accepts_array_and_delegates_each(): void + { + $seeder = new SeederTest_CallingSeeder(); + + $runner = new class('/tmp') extends SeederRunner { + public array $called = []; + public function runClass(string $class): SeederResult + { + $this->called[] = $class; + return SeederResult::ok($class, 0.0); + } + }; + + $seeder->setRunner($runner); + $results = $seeder->doCall(['A', 'B', 'C']); + + $this->assertCount(3, $results); + $this->assertSame(['A', 'B', 'C'], $runner->called); + foreach ($results as $r) { + $this->assertTrue($r->success); + } + } + + public function test_call_returns_failed_result_when_runner_missing(): void + { + $seeder = new SeederTest_CallingSeeder(); + // Intentionally do NOT setRunner. + + $results = $seeder->doCall('OrphanSeeder'); + + $this->assertCount(1, $results); + $this->assertFalse($results[0]->success); + $this->assertSame('OrphanSeeder', $results[0]->name); + $this->assertStringContainsString('No runner', $results[0]->error); + } + + public function test_connection_defaults_to_null(): void + { + $seeder = new SeederTest_FakeSeeder(); + $this->assertNull($seeder->connection); + } +}