diff --git a/composer.json b/composer.json index 84b1400..07c8bcc 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ }, "files": [ "src/internal/filesystem/functions.php", - "src/internal/functions.php" + "src/package/functions.php" ] }, "autoload-dev": { diff --git a/src/Config.php b/src/Config.php index 66fce1c..2f0b515 100644 --- a/src/Config.php +++ b/src/Config.php @@ -5,6 +5,8 @@ namespace dbschemix\core; use dbschemix\core\exception\ConfigurationException; +use dbschemix\core\internal\template\Factory; +use dbschemix\core\template\FactoryInterface; /** * @api @@ -17,7 +19,7 @@ */ public function __construct( public string $table = 'migration', - public template\FactoryInterface $templFactory = new template\Factory(), + public FactoryInterface $templFactory = new Factory(), ) { if (preg_match('/^\w+$/', $table) !== 1) { throw new ConfigurationException( diff --git a/src/Context.php b/src/Context.php index 5a5bd38..0d1a7e9 100644 --- a/src/Context.php +++ b/src/Context.php @@ -5,6 +5,7 @@ namespace dbschemix\core; /** + * @api * @infection-ignore-all IncrementInteger */ final readonly class Context diff --git a/src/command/Command.php b/src/command/Command.php index 1c75033..2c8422c 100644 --- a/src/command/Command.php +++ b/src/command/Command.php @@ -11,7 +11,9 @@ use dbschemix\core\Context; /** - * @psalm-internal dbschemix + * SQL default command + * + * @api */ final readonly class Command implements CommandInterface { @@ -37,20 +39,11 @@ public function fetchApplied(Options $options = new Options()): array $limit = 'LIMIT ' . $options->limit; } - /** @var non-empty-string $query */ - $query = str_replace( - [ - ':table', - '[WHERE]', - '[LIMIT]', - ], - [ - $this->config->table, - $where, - $limit, - ], - 'SELECT name, version FROM :table [WHERE] ORDER BY atime DESC, name DESC [LIMIT]', - ); + $query = 'SELECT name, version FROM ' + . $this->config->table + . ($where !== '' ? ' ' . $where : '') + . ' ORDER BY atime DESC, name DESC' + . ($limit !== '' ? ' ' . $limit : ''); return $this->connection->fetchRecord($query, $params); } diff --git a/src/command/CommandInterface.php b/src/command/CommandInterface.php index ac4bd67..cdac53a 100644 --- a/src/command/CommandInterface.php +++ b/src/command/CommandInterface.php @@ -7,6 +7,30 @@ use Throwable; use dbschemix\core\Context; +/** + * Command port: the contract a database driver implementation fulfills to + * run core migration workflow operations. + * + * Audience: driver implementors (the in-tree adapter + * `internal\command\Command` plus downstream packages such as + * `dbschemix/pdo`, `dbschemix/pgsql`, `dbschemix/clickhouse`). End consumers + * interact with `Migrator`, whose exception contract is fully typed. + * + * Exception policy: each method below is declared `@throws Throwable` + * intentionally. A driver implementation may surface whatever its + * underlying client throws (\PDOException, mysqli_sql_exception, ClickHouse + * client exceptions, ...) plus library-typed exceptions such as + * `PrepareException`. The core workflow catches these at its boundary + * (Workflow::run, Workflow::getAppliedMigrations) and wraps them into + * typed `MigratorException` subtypes — `ActionException` for migration + * failures, `InitializationException` for system-state reads — so the + * typed exception contract is restored before reaching the consumer of + * `Migrator`. Driver implementors are NOT required to pre-wrap their + * underlying exceptions; the workflow's `catch (Throwable)` is the + * wrapping point by design. + * + * @api + */ interface CommandInterface { /** diff --git a/src/command/Options.php b/src/command/Options.php index c76f799..8401991 100644 --- a/src/command/Options.php +++ b/src/command/Options.php @@ -6,6 +6,9 @@ use dbschemix\core\InputOptions; +/** + * @api + */ final readonly class Options { /** diff --git a/src/connection/ConnectionInterface.php b/src/connection/ConnectionInterface.php index 55b1d96..a0a7fdf 100644 --- a/src/connection/ConnectionInterface.php +++ b/src/connection/ConnectionInterface.php @@ -4,6 +4,9 @@ namespace dbschemix\core\connection; +/** + * @api + */ interface ConnectionInterface extends StatementInterface { public function beginTransaction(): TransactionInterface; diff --git a/src/connection/DriverInterface.php b/src/connection/DriverInterface.php index e1cac68..bce39d5 100644 --- a/src/connection/DriverInterface.php +++ b/src/connection/DriverInterface.php @@ -8,6 +8,9 @@ use dbschemix\core\exception\ConnectionException; use dbschemix\core\Config; +/** + * @api + */ interface DriverInterface { /** diff --git a/src/connection/StatementInterface.php b/src/connection/StatementInterface.php index cb496a3..74c1faf 100644 --- a/src/connection/StatementInterface.php +++ b/src/connection/StatementInterface.php @@ -4,6 +4,9 @@ namespace dbschemix\core\connection; +/** + * @api + */ interface StatementInterface { /** diff --git a/src/connection/TransactionInterface.php b/src/connection/TransactionInterface.php index 3035b44..0b89e0e 100644 --- a/src/connection/TransactionInterface.php +++ b/src/connection/TransactionInterface.php @@ -4,6 +4,9 @@ namespace dbschemix\core\connection; +/** + * @api + */ interface TransactionInterface extends StatementInterface { public function isActive(): bool; diff --git a/src/event/Event.php b/src/event/Event.php index 7f0d958..3f68350 100644 --- a/src/event/Event.php +++ b/src/event/Event.php @@ -4,19 +4,69 @@ namespace dbschemix\core\event; +/** + * Library-wide event identifiers and the stable enum-to-payload mapping. + * + * Each case below documents the concrete {@see EventInterface} implementation + * a subscriber will receive when the event fires. Subscribers that need the + * structured payload (Context, Throwable, dbName, action) may safely + * `instanceof` onto the dispatched class — the mapping is part of the @api + * contract. + * + * @api + */ enum Event: string { + /** + * Reading the migration-tracking table failed during initialization. + * + * Dispatched payload: {@see ExceptionEvent} (dbName, exception). + */ case InitializationError = 'initialization-error-event'; + /** + * The driver could not open a connection or build a CommandInterface. + * + * Dispatched payload: {@see ExceptionEvent} (dbName, exception). + */ case ConnectionError = 'connection-error-event'; + /** + * Configuration is invalid (requested database is not in the list, + * table name is malformed, required arguments missing, ...). + * + * Dispatched payload: {@see ExceptionEvent} (dbName, exception). + */ case ConfigurationError = 'configuration-error-event'; + /** + * A filesystem operation failed (missing migration directory, unwritable + * path, unreadable file, ...). + * + * Dispatched payload: {@see ExceptionEvent} (dbName, exception). + */ case FilesystemError = 'filesystem-error-event'; + /** + * Filesystem soft event: directory exists but contains no migration files. + * Informational; not an error. The carried exception holds the notice + * message for logging convenience. + * + * Dispatched payload: {@see ExceptionEvent} (dbName, exception). + */ case FilesystemNotice = 'filesystem-notice-event'; + /** + * A single migration file has been applied successfully. + * + * Dispatched payload: {@see MigrateSuccessEvent} (action, context). + */ case MigrateSuccess = 'migrate-success-event'; + /** + * A single migration file failed to apply. + * + * Dispatched payload: {@see MigrateErrorEvent} (action, context, exception). + */ case MigrateError = 'migrate-error-event'; } diff --git a/src/event/EventAction.php b/src/event/EventAction.php index 300dbbe..53f3f74 100644 --- a/src/event/EventAction.php +++ b/src/event/EventAction.php @@ -4,6 +4,9 @@ namespace dbschemix\core\event; +/** + * @psalm-internal dbschemix\core + */ enum EventAction { case up; diff --git a/src/event/EventDispatcher.php b/src/event/EventDispatcher.php index cdc1bff..c7cf1da 100644 --- a/src/event/EventDispatcher.php +++ b/src/event/EventDispatcher.php @@ -4,6 +4,7 @@ namespace dbschemix\core\event; +use Closure; use Throwable; /** @@ -12,7 +13,7 @@ final readonly class EventDispatcher { /** - * @var array> + * @var array> */ private array $eventHandlers; @@ -23,21 +24,48 @@ public function __construct(array $eventSubscribers) { $subscriptions = []; foreach ($eventSubscribers as $subscriber) { - foreach ($subscriber->subscriptions() as $name => $callback) { - $subscriptions[$name][] = $callback; + foreach ($subscriber->subscriptions() as $subscription) { + $subscriptions[$subscription->event->value][] = $subscription->callback; } } $this->eventHandlers = $subscriptions; } + /** + * Notifies subscribers of an event. + * + * Subscriber callbacks MUST NOT throw. If one does, the exception is captured + * and reported through trigger_error(E_USER_WARNING); the loop continues so + * remaining subscribers still receive the event, and the migration is not + * aborted by a subscriber bug. + * + * This isolation is intentional: trigger() is called from inside the migration's + * own try/catch flow (see Workflow::run()), and an exception propagating out + * of a subscriber would be misattributed as a migration failure — corrupting the + * success/error event contract and potentially shadowing the original migration + * error. + * + * Hosts that want fail-fast on subscriber failures may install a strict error + * handler (set_error_handler) that converts E_USER_WARNING into an exception; + * that is an explicit opt-in by the host. + */ public function trigger(Event $name, EventInterface $event): void { if (array_key_exists($name->value, $this->eventHandlers)) { foreach ($this->eventHandlers[$name->value] as $subscriberCallback) { try { $subscriberCallback($name, $event); - } catch (Throwable) { + } catch (Throwable $exception) { + trigger_error( + sprintf( + 'dbschemix\core: subscriber callback for event "%s" failed (%s): %s', + $name->value, + $exception::class, + $exception->getMessage(), + ), + E_USER_WARNING, + ); } } } diff --git a/src/event/EventInterface.php b/src/event/EventInterface.php index 95394f6..8d84dd8 100644 --- a/src/event/EventInterface.php +++ b/src/event/EventInterface.php @@ -4,6 +4,22 @@ namespace dbschemix\core\event; +/** + * Common surface across all dispatched events. + * + * Provides generic-logging-friendly accessors (getName, getMessage) suitable + * for subscribers that only need a human-readable line. Structured payload + * data (Context, Throwable, dbName, action) lives on the concrete event + * class — {@see MigrateSuccessEvent}, {@see MigrateErrorEvent}, + * {@see ExceptionEvent} — as public readonly properties. + * + * Subscribers that need structured data should type-narrow via `instanceof` + * onto the concrete class. The Event enum case → concrete event class + * mapping is part of the @api contract and is documented per case in + * {@see Event}. + * + * @api + */ interface EventInterface { /** diff --git a/src/event/EventPublisherInterface.php b/src/event/EventPublisherInterface.php deleted file mode 100644 index f0cbf9c..0000000 --- a/src/event/EventPublisherInterface.php +++ /dev/null @@ -1,12 +0,0 @@ - + * Returns the list of (event, callback) subscriptions this subscriber wants. + * + * Subscriber callbacks MUST NOT throw. If a callback throws, the library + * reports the failure via trigger_error(E_USER_WARNING) and continues: + * other subscribers still receive the event, and the migration is not + * aborted. Subscribers that need to react to their own failures must + * wrap their body in their own try/catch. + * + * @return list */ public function subscriptions(): array; } diff --git a/src/event/ExceptionEvent.php b/src/event/ExceptionEvent.php index 1713e34..b56b3bb 100644 --- a/src/event/ExceptionEvent.php +++ b/src/event/ExceptionEvent.php @@ -7,6 +7,9 @@ use Override; use Throwable; +/** + * @api + */ final readonly class ExceptionEvent implements EventInterface { /** diff --git a/src/event/MigrateErrorEvent.php b/src/event/MigrateErrorEvent.php index 64c0a6c..6445f6e 100644 --- a/src/event/MigrateErrorEvent.php +++ b/src/event/MigrateErrorEvent.php @@ -8,6 +8,9 @@ use Throwable; use dbschemix\core\Context; +/** + * @api + */ final readonly class MigrateErrorEvent implements EventInterface { /** @@ -30,7 +33,7 @@ public function getName(): string public function getMessage(): string { return sprintf( - "[%s] %s: %s, vers: %d\r\n%s", + "[%s] %s: %s, vers: %d\n%s", $this->context->dbName, $this->action, $this->context->filename, diff --git a/src/event/MigrateSuccessEvent.php b/src/event/MigrateSuccessEvent.php index 1629ae6..b83b17d 100644 --- a/src/event/MigrateSuccessEvent.php +++ b/src/event/MigrateSuccessEvent.php @@ -7,6 +7,9 @@ use Override; use dbschemix\core\Context; +/** + * @api + */ final readonly class MigrateSuccessEvent implements EventInterface { /** diff --git a/src/event/Subscription.php b/src/event/Subscription.php new file mode 100644 index 0000000..071a995 --- /dev/null +++ b/src/event/Subscription.php @@ -0,0 +1,22 @@ + $handler + * @param Closure(filesystem\Action):Iterator $handler * @return iterable * @throws ConfigurationException */ - private function iteratorHandler(Migration $migration, callable $handler, bool $useException = true): iterable + private function iteratorHandler(Migration $migration, Closure $handler, bool $useException = true): iterable { try { $iterator = $handler(new filesystem\Action($migration->path)); diff --git a/src/internal/filesystem/Action.php b/src/internal/filesystem/Action.php index 23368ac..2f65d97 100644 --- a/src/internal/filesystem/Action.php +++ b/src/internal/filesystem/Action.php @@ -85,7 +85,7 @@ public function down(array $listApplied, Options $options = new Options()): Iter public function fixture(Options $options = new Options()): Iterator { $iternum = 0; - foreach ($this->makeIterator(joinBasename($this->path, '-fixture')) as $matchFilename) { + foreach ($this->makeIterator(joinSuffix($this->path, '-fixture')) as $matchFilename) { if ($options->limit > 0 && $iternum >= $options->limit) { return; } @@ -107,7 +107,7 @@ public function fixture(Options $options = new Options()): Iterator */ public function repeatable(): Iterator { - foreach ($this->makeIterator(joinBasename($this->path, '-repeatable')) as $matchFilename) { + foreach ($this->makeIterator(joinSuffix($this->path, '-repeatable')) as $matchFilename) { $filepath = $matchFilename[0]; $command = $this->prepareCommand($filepath, ActionType::UP); if ($command !== null) { diff --git a/src/internal/filesystem/ActionType.php b/src/internal/filesystem/ActionType.php index dd3547e..e8f1708 100644 --- a/src/internal/filesystem/ActionType.php +++ b/src/internal/filesystem/ActionType.php @@ -11,5 +11,4 @@ enum ActionType: string { case UP = 'up'; case DOWN = 'down'; - case SKIP = 'skip'; } diff --git a/src/internal/filesystem/functions.php b/src/internal/filesystem/functions.php index fe4ce1d..f4579c1 100644 --- a/src/internal/filesystem/functions.php +++ b/src/internal/filesystem/functions.php @@ -16,13 +16,13 @@ function normalizePath(string $path): string /** * @param non-empty-string $path - * @param non-empty-string $postfix + * @param non-empty-string $suffix * @return non-empty-string * @infection-ignore-all */ -function joinBasename(string $path, string $postfix): string +function joinSuffix(string $path, string $suffix): string { - return rtrim(trim($path), '/') . rtrim($postfix, '/') . '/'; + return rtrim(trim($path), '/') . rtrim($suffix, '/') . '/'; } /** diff --git a/src/template/Factory.php b/src/internal/template/Factory.php similarity index 84% rename from src/template/Factory.php rename to src/internal/template/Factory.php index b33a92d..7570249 100644 --- a/src/template/Factory.php +++ b/src/internal/template/Factory.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace dbschemix\core\template; +namespace dbschemix\core\internal\template; use Override; +use dbschemix\core\template\FactoryInterface; /** * @psalm-internal dbschemix\core diff --git a/src/internal/functions.php b/src/package/functions.php similarity index 90% rename from src/internal/functions.php rename to src/package/functions.php index 57b5c0b..0fe0f39 100644 --- a/src/internal/functions.php +++ b/src/package/functions.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace dbschemix\core\internal; +namespace dbschemix\core\package; use OutOfBoundsException; use Composer\InstalledVersions; @@ -13,7 +13,7 @@ * @return non-empty-string * @throws OutOfBoundsException of package is not installed */ -function get_package_version(string $package): string +function version(string $package): string { /** * @var array $versions @@ -42,7 +42,7 @@ function get_package_version(string $package): string * @return non-empty-string * @throws OutOfBoundsException of package is not installed */ -function get_package_path(string $package): string +function path(string $package): string { $packagePath = InstalledVersions::getInstallPath($package); diff --git a/src/template/FactoryInterface.php b/src/template/FactoryInterface.php index 0e67539..e6d9aeb 100644 --- a/src/template/FactoryInterface.php +++ b/src/template/FactoryInterface.php @@ -4,6 +4,9 @@ namespace dbschemix\core\template; +/** + * @api + */ interface FactoryInterface { /** @@ -12,5 +15,8 @@ interface FactoryInterface */ public function makeName(string $name): string; + /** + * @return non-empty-string + */ public function makeBody(): string; } diff --git a/tests/InputOptionsTest.php b/tests/InputOptionsTest.php new file mode 100644 index 0000000..f3e4a85 --- /dev/null +++ b/tests/InputOptionsTest.php @@ -0,0 +1,242 @@ +limit); + self::assertSame(0, $options->version); + self::assertFalse($options->dryRun); + self::assertNull($options->dbName); + self::assertNull($options->migrationName); + self::assertFalse($options->exactlyAll); + self::assertFalse($options->hasRepeatable()); + self::assertFalse($options->hasApplyLatestVersion()); + } + + public function testWithResetLimitClearsOnlyLimit(): void + { + $original = new InputOptions( + limit: 5, + version: 42, + dryRun: true, + dbName: 'mainDb', + migrationName: 'add_users', + exactlyAll: true, + hasRepeatable: true, + applyLatestVersion: true, + ); + + $next = $original->withResetLimit(); + + // limit dropped to default + self::assertSame(0, $next->limit); + + // everything else preserved + self::assertSame(42, $next->version); + self::assertTrue($next->dryRun); + self::assertSame('mainDb', $next->dbName); + self::assertSame('add_users', $next->migrationName); + self::assertTrue($next->exactlyAll); + + // private flags survive — observed indirectly + // hasRepeatable: true survives, but dryRun=true is also true, so hasRepeatable() returns false + self::assertFalse($next->hasRepeatable()); + // applyLatestVersion: true survives; with version=42 (non-zero), hasApplyLatestVersion still returns false + self::assertFalse($next->hasApplyLatestVersion()); + + // original is not mutated + self::assertSame(5, $original->limit); + } + + public function testWithVersionSetsVersionAndDropsLimitAndFlags(): void + { + $original = new InputOptions( + limit: 5, + version: 1, + dryRun: true, + dbName: 'mainDb', + migrationName: 'add_users', + exactlyAll: true, + hasRepeatable: true, + applyLatestVersion: true, + ); + + $next = $original->withVersion(42); + + // explicit set + self::assertSame(42, $next->version); + + // preserved + self::assertTrue($next->dryRun); + self::assertSame('mainDb', $next->dbName); + self::assertSame('add_users', $next->migrationName); + + // dropped to defaults — withVersion only forwards (version, dryRun, dbName, migrationName) + self::assertSame(0, $next->limit); + self::assertFalse($next->exactlyAll); + // hasRepeatable was true; not forwarded + self::assertFalse($next->hasRepeatable()); + // applyLatestVersion was true; not forwarded + self::assertFalse($next->hasApplyLatestVersion()); + + // original is not mutated + self::assertSame(1, $original->version); + } + + public function testWithExactlyAllSetsExactlyAllAndDropsApplyLatestVersion(): void + { + $original = new InputOptions( + limit: 5, + version: 42, + dryRun: true, + dbName: 'mainDb', + migrationName: 'add_users', + exactlyAll: false, + hasRepeatable: true, + applyLatestVersion: true, + ); + + $next = $original->withExactlyAll(); + + // explicit set + self::assertTrue($next->exactlyAll); + + // preserved + self::assertSame(5, $next->limit); + self::assertSame(42, $next->version); + self::assertTrue($next->dryRun); + self::assertSame('mainDb', $next->dbName); + self::assertSame('add_users', $next->migrationName); + + // private flags: hasRepeatable preserved; applyLatestVersion dropped + // — withExactlyAll forwards hasRepeatable but not applyLatestVersion + // hasRepeatable: true forwarded, but dryRun=true so hasRepeatable() returns false + self::assertFalse($next->hasRepeatable()); + // applyLatestVersion dropped + version=42 makes it false anyway + self::assertFalse($next->hasApplyLatestVersion()); + + // original is not mutated + self::assertFalse($original->exactlyAll); + } + + public function testWithersReturnNewInstances(): void + { + $original = new InputOptions(limit: 5); + + self::assertNotSame($original, $original->withResetLimit()); + self::assertNotSame($original, $original->withVersion(1)); + self::assertNotSame($original, $original->withExactlyAll()); + } + + /** + * @return iterable + */ + public static function hasApplyLatestVersionCases(): iterable + { + yield 'all false: not requested' => [ + 'applyLatestVersion' => false, + 'version' => 0, + 'limit' => 0, + 'want' => false, + ]; + yield 'requested, no version, no limit: yes' => [ + 'applyLatestVersion' => true, + 'version' => 0, + 'limit' => 0, + 'want' => true, + ]; + yield 'requested, version set: no (explicit version overrides)' => [ + 'applyLatestVersion' => true, + 'version' => 42, + 'limit' => 0, + 'want' => false, + ]; + yield 'requested, limit=1: no (single-step migration excludes latest-aggregate)' => [ + 'applyLatestVersion' => true, + 'version' => 0, + 'limit' => 1, + 'want' => false, + ]; + yield 'requested, limit>1: yes' => [ + 'applyLatestVersion' => true, + 'version' => 0, + 'limit' => 5, + 'want' => true, + ]; + } + + /** + * @param non-negative-int $version + * @param non-negative-int $limit + */ + #[DataProvider('hasApplyLatestVersionCases')] + public function testHasApplyLatestVersion( + bool $applyLatestVersion, + int $version, + int $limit, + bool $want, + ): void { + $options = new InputOptions( + limit: $limit, + version: $version, + applyLatestVersion: $applyLatestVersion, + ); + + self::assertSame($want, $options->hasApplyLatestVersion()); + } + + /** + * @return iterable + */ + public static function hasRepeatableCases(): iterable + { + yield 'not requested' => [ + 'hasRepeatable' => false, + 'dryRun' => false, + 'want' => false, + ]; + yield 'requested, not dry-run: yes' => [ + 'hasRepeatable' => true, + 'dryRun' => false, + 'want' => true, + ]; + yield 'requested, dry-run: no (dry-run inhibits repeatable replay)' => [ + 'hasRepeatable' => true, + 'dryRun' => true, + 'want' => false, + ]; + yield 'not requested, dry-run: no' => [ + 'hasRepeatable' => false, + 'dryRun' => true, + 'want' => false, + ]; + } + + #[DataProvider('hasRepeatableCases')] + public function testHasRepeatable(bool $hasRepeatable, bool $dryRun, bool $want): void + { + $options = new InputOptions( + dryRun: $dryRun, + hasRepeatable: $hasRepeatable, + ); + + self::assertSame($want, $options->hasRepeatable()); + } +} diff --git a/tests/internal/PathFunctionsTest.php b/tests/internal/PathFunctionsTest.php new file mode 100644 index 0000000..c2b9d1d --- /dev/null +++ b/tests/internal/PathFunctionsTest.php @@ -0,0 +1,93 @@ + + */ + public static function normalizePathCases(): iterable + { + yield 'absolute, no trailing slash' => ['/var/migrations', '/var/migrations/']; + yield 'absolute, single trailing slash' => ['/var/migrations/', '/var/migrations/']; + yield 'absolute, multiple trailing slashes' => ['/var/migrations///', '/var/migrations/']; + yield 'relative, no trailing slash' => ['app/migrations', 'app/migrations/']; + yield 'leading whitespace stripped' => [' /var/m', '/var/m/']; + yield 'trailing whitespace stripped' => ['/var/m ', '/var/m/']; + yield 'whitespace and slash together' => [' /var/m/ ', '/var/m/']; + yield 'root path' => ['/', '/']; + } + + /** + * @param non-empty-string $input + * @param non-empty-string $expected + */ + #[DataProvider('normalizePathCases')] + public function testNormalizePath(string $input, string $expected): void + { + self::assertSame($expected, normalizePath($input)); + } + + /** + * @return iterable + */ + public static function joinSuffixCases(): iterable + { + yield 'simple suffix' => ['/var/m', '-fixture', '/var/m-fixture/']; + yield 'path with trailing slash, plain suffix' => ['/var/m/', '-repeatable', '/var/m-repeatable/']; + yield 'suffix with trailing slash stripped' => ['/var/m', '-fixture/', '/var/m-fixture/']; + yield 'both with trailing slashes' => ['/var/m/', '-fixture/', '/var/m-fixture/']; + yield 'whitespace in path stripped' => [' /var/m ', '-x', '/var/m-x/']; + } + + /** + * @param non-empty-string $path + * @param non-empty-string $suffix + * @param non-empty-string $expected + */ + #[DataProvider('joinSuffixCases')] + public function testJoinSuffix(string $path, string $suffix, string $expected): void + { + self::assertSame($expected, joinSuffix($path, $suffix)); + } + + /** + * @return iterable + */ + public static function joinFilenameCases(): iterable + { + yield 'plain' => ['/var/m', 'file.sql', '/var/m/file.sql']; + yield 'path with trailing slash' => ['/var/m/', 'file.sql', '/var/m/file.sql']; + yield 'path with multiple trailing slashes' => ['/var/m///', 'file.sql', '/var/m/file.sql']; + yield 'whitespace in path stripped' => [' /var/m ', 'file.sql', '/var/m/file.sql']; + yield 'filename with timestamp prefix' => [ + '/var/m', + '202501011024_entity.sql', + '/var/m/202501011024_entity.sql', + ]; + } + + /** + * @param non-empty-string $path + * @param non-empty-string $filename + * @param non-empty-string $expected + */ + #[DataProvider('joinFilenameCases')] + public function testJoinFilename(string $path, string $filename, string $expected): void + { + self::assertSame($expected, joinFilename($path, $filename)); + } +} diff --git a/tests/package/FunctionsTest.php b/tests/package/FunctionsTest.php new file mode 100644 index 0000000..546ca76 --- /dev/null +++ b/tests/package/FunctionsTest.php @@ -0,0 +1,58 @@ +expectException(OutOfBoundsException::class); + + version('dbschemix/nonexistent-fake-package'); + } + + public function testVersionIsCachedAcrossCalls(): void + { + // The function uses a static cache keyed by package name; second call + // must return the same value and not re-query Composer\InstalledVersions. + $first = version('phpunit/phpunit'); + $second = version('phpunit/phpunit'); + + self::assertSame($first, $second); + } + + public function testPathReturnsAbsolutePathForInstalledPackage(): void + { + $result = path('phpunit/phpunit'); + + self::assertNotEmpty($result); + self::assertDirectoryExists($result); + } + + public function testPathThrowsForUnknownPackage(): void + { + $this->expectException(OutOfBoundsException::class); + + path('dbschemix/nonexistent-fake-package'); + } +} diff --git a/tests/stub/TestSubscriber.php b/tests/stub/TestSubscriber.php index e713e39..769003b 100644 --- a/tests/stub/TestSubscriber.php +++ b/tests/stub/TestSubscriber.php @@ -8,6 +8,7 @@ use dbschemix\core\event\Event; use dbschemix\core\event\EventInterface; use dbschemix\core\event\EventSubscriberInterface; +use dbschemix\core\event\Subscription; final class TestSubscriber implements EventSubscriberInterface { @@ -16,18 +17,17 @@ final class TestSubscriber implements EventSubscriberInterface */ private array $storage = []; + /** + * @return list + */ #[Override] public function subscriptions(): array { $subscriptions = []; foreach (Event::cases() as $event) { - $subscriptions[$event->value] = $this->set(...); + $subscriptions[] = new Subscription($event, $this->set(...)); } - /** - * @var non-empty-array $subscriptions - * @phpstan-ignore varTag.nativeType - */ return $subscriptions; } diff --git a/tests/workflow/ConfigurationTest.php b/tests/workflow/ConfigurationTest.php index efaa8cb..060b720 100644 --- a/tests/workflow/ConfigurationTest.php +++ b/tests/workflow/ConfigurationTest.php @@ -40,7 +40,6 @@ public function testConnectionException(): void ); $this->expectException(ConnectionException::class); - $this->expectExceptionMessage('Connection error'); $migrator->init(); }