diff --git a/.github/workflows/ci-mssql.yml b/.github/workflows/ci-mssql.yml new file mode 100644 index 0000000..0401c56 --- /dev/null +++ b/.github/workflows/ci-mssql.yml @@ -0,0 +1,86 @@ +on: + - pull_request + - push + +name: ci-mssql + +jobs: + tests: + name: PHP ${{ matrix.php }}-mssql-${{ matrix.mssql }} + + env: + key: cache + + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - php: '7.2' + extensions: pdo, pdo_sqlsrv-5.8.1 + mssql: 'server:2017-latest' + - php: '7.3' + extensions: pdo, pdo_sqlsrv-5.8.1 + mssql: 'server:2017-latest' + - php: '7.4' + extensions: pdo, pdo_sqlsrv + mssql: 'server:2017-latest' + - php: '7.4' + extensions: pdo, pdo_sqlsrv + mssql: 'server:2019-latest' + - php: '8.0' + extensions: pdo, pdo_sqlsrv + mssql: 'server:2017-latest' + - php: '8.0' + extensions: pdo, pdo_sqlsrv + mssql: 'server:2019-latest' + + services: + mssql: + image: mcr.microsoft.com/mssql/${{ matrix.mssql }} + env: + SA_PASSWORD: SSpaSS__1 + ACCEPT_EULA: Y + MSSQL_PID: Developer + ports: + - 11433:1433 + options: --name=mssql --health-cmd="/opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P 'SSpaSS__1' -Q 'SELECT 1'" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - name: Checkout + uses: actions/checkout@v2.3.4 + + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: ${{ matrix.extensions }} + ini-values: date.timezone='UTC' + tools: composer:v2, pecl + + - name: Determine composer cache directory on Linux + run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV + + - name: Cache dependencies installed with composer + uses: actions/cache@v2 + with: + path: ${{ env.COMPOSER_CACHE_DIR }} + key: php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + php${{ matrix.php }}-composer- + + - name: Update composer + run: composer self-update + + - name: Install dependencies with composer + run: composer update --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi + + - name: Install dependencies with composer php 8.0 + if: matrix.php == '8.0' + run: composer update --ignore-platform-reqs --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi + + - name: Run tests with phpunit without coverage + env: + DB: sqlserver + run: vendor/bin/phpunit --group driver-sqlserver --colors=always diff --git a/.github/workflows/ci-mysql.yml b/.github/workflows/ci-mysql.yml new file mode 100644 index 0000000..be40e2a --- /dev/null +++ b/.github/workflows/ci-mysql.yml @@ -0,0 +1,89 @@ +on: + - pull_request + - push + +name: ci-mysql + +jobs: + tests: + name: PHP ${{ matrix.php-version }}-mysql-${{ matrix.mysql-version }} + env: + extensions: curl, intl, pdo, pdo_mysql + key: cache-v1 + + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + + php-version: + - "7.2" + - "7.3" + - "7.4" + - "8.0" + + mysql-version: + - "5.7" + - "8.0" + + services: + mysql: + image: mysql:${{ matrix.mysql-version }} + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: spiral + MYSQL_AUTHENTICATION_PLUGIN: mysql_native_password + ports: + - 13306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup cache environment + id: cache-env + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php-version }} + extensions: ${{ env.extensions }} + key: ${{ env.key }} + + - name: Cache extensions + uses: actions/cache@v1 + with: + path: ${{ steps.cache-env.outputs.dir }} + key: ${{ steps.cache-env.outputs.key }} + restore-keys: ${{ steps.cache-env.outputs.key }} + + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: ${{ env.extensions }} + ini-values: date.timezone='UTC' + coverage: pcov + + - name: Determine composer cache directory + if: matrix.os == 'ubuntu-latest' + run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV + + - name: Cache dependencies installed with composer + uses: actions/cache@v1 + with: + path: ${{ env.COMPOSER_CACHE_DIR }} + key: php${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + php${{ matrix.php-version }}-composer-${{ matrix.dependencies }}- + + - name: Install dependencies with composer + run: composer update --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi + + - name: Run mysql tests with phpunit + env: + DB: mysql + MYSQL: ${{ matrix.mysql-version }} + run: vendor/bin/phpunit --group driver-mysql --colors=always diff --git a/.github/workflows/ci-pgsql.yml b/.github/workflows/ci-pgsql.yml new file mode 100644 index 0000000..4c31a6d --- /dev/null +++ b/.github/workflows/ci-pgsql.yml @@ -0,0 +1,90 @@ +on: + - pull_request + - push + +name: ci-pgsql + +jobs: + tests: + name: PHP ${{ matrix.php-version }}-pgsql-${{ matrix.pgsql-version }} + env: + extensions: curl, intl, pdo, pdo_pgsql + key: cache-v1 + + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + php-version: + - "7.2" + - "7.3" + - "7.4" + - "8.0" + + pgsql-version: + - "10" + - "11" + - "12" + - "13" + + services: + postgres: + image: postgres:${{ matrix.pgsql-version }} + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: spiral + ports: + - 15432:5432 + options: --name=postgres --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup cache environment + id: cache-env + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php-version }} + extensions: ${{ env.extensions }} + key: ${{ env.key }} + + - name: Cache extensions + uses: actions/cache@v1 + with: + path: ${{ steps.cache-env.outputs.dir }} + key: ${{ steps.cache-env.outputs.key }} + restore-keys: ${{ steps.cache-env.outputs.key }} + + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: ${{ env.extensions }} + ini-values: date.timezone='UTC' + coverage: pcov + + - name: Determine composer cache directory + if: matrix.os == 'ubuntu-latest' + run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV + + - name: Cache dependencies installed with composer + uses: actions/cache@v1 + with: + path: ${{ env.COMPOSER_CACHE_DIR }} + key: php${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + php${{ matrix.php-version }}-composer-${{ matrix.dependencies }}- + + - name: Install dependencies with composer + run: composer update --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi + + - name: Run pgsql tests with phpunit + env: + DB: postgres + POSTGRES: ${{ matrix.pgsql-version }} + run: vendor/bin/phpunit --group driver-postgres --colors=always diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3eb2678..a3a8b7d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,11 +24,11 @@ jobs: run: vendor/bin/spiral-cs check src tests test: needs: lint - name: Test PHP ${{ matrix.php-versions }} + name: Test PHP ${{ matrix.php-versions }} with Code Coverage runs-on: ubuntu-latest strategy: matrix: - php-versions: ['7.2', '7.3', '7.4'] + php-versions: ['7.2', '7.3', '7.4', '8.0'] steps: - name: Checkout uses: actions/checkout@v2 @@ -38,18 +38,12 @@ jobs: docker-compose up -d cd .. - name: Setup PHP ${{ matrix.php-versions }} - uses: shivammathur/setup-php@v1 + uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} coverage: pcov tools: pecl - extensions: mbstring, pdo, pdo_sqlsrv - - name: Install MS SQL Server deps - run: | - bash ./tests/install-sqlsrv.sh - sudo sed -i.bak '/^extension="pdo_sqlsrv.so"/d' /etc/php/${{ matrix.php-versions }}/cli/php.ini - sudo bash -c 'printf "; priority=30\nextension=pdo_sqlsrv.so\n" > /etc/php/${{ matrix.php-versions }}/mods-available/pdo_sqlsrv.ini' - sudo phpenmod -s cli -v ${{ matrix.php-versions }} pdo_sqlsrv + extensions: mbstring, pdo, pdo_sqlite, pdo_pgsql, pdo_sqlsrv, pdo_mysql - name: Get Composer Cache Directory id: composer-cache run: echo "::set-output name=dir::$(composer config cache-files-dir)" @@ -65,77 +59,29 @@ jobs: run: | vendor/bin/phpunit --coverage-clover=coverage.xml - name: Upload coverage to Codecov + continue-on-error: true # if is fork uses: codecov/codecov-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.xml - test_postgres: + + sqlite: needs: lint - name: Test PostgreSQL ${{ matrix.configs.postgres-version }} + name: SQLite PHP ${{ matrix.php-versions }} runs-on: ubuntu-latest strategy: matrix: - configs: [ - {php-version: 7.2, postgres-version: 9.6}, - {php-version: 7.3, postgres-version: 10}, - {php-version: 7.3, postgres-version: 11} - ] - services: - postgres: - image: postgres:${{ matrix.configs.postgres-version }} - ports: - - 5432:5432 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: spiral - options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + php-versions: ['7.2', '7.3', '7.4', '8.0'] steps: - name: Checkout uses: actions/checkout@v2 - - name: Setup PHP ${{ matrix.configs.php-version }} - run: sudo update-alternatives --set php /usr/bin/php${{ matrix.configs.php-version }} - - name: Get Composer Cache Directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - name: Restore Composer Cache - uses: actions/cache@v1 + - name: Setup PHP ${{ matrix.php-versions }} + uses: shivammathur/setup-php@v1 with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Install Dependencies - run: composer install --no-interaction --prefer-dist - - name: Execute Tests - env: - DB: postgres - POSTGRES: ${{ matrix.configs.postgres-version }} - run: | - vendor/bin/phpunit tests/Migrations/Postgres - test_mariadb: - needs: lint - name: Test MariaDB ${{ matrix.configs.mariadb-version }} - runs-on: ubuntu-latest - strategy: - matrix: - configs: [ -# {php-version: 7.2, mariadb-version: 10.2}, - {php-version: 7.3, mariadb-version: 10.4} - ] - services: - mariadb: - image: mariadb:${{ matrix.configs.mariadb-version }} - ports: - - 23306:3306 - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: spiral - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Setup PHP ${{ matrix.configs.php-version }} - run: sudo update-alternatives --set php /usr/bin/php${{ matrix.configs.php-version }} + php-version: ${{ matrix.php-versions }} + coverage: pcov + tools: pecl + extensions: mbstring, pdo, pdo_sqlite - name: Get Composer Cache Directory id: composer-cache run: echo "::set-output name=dir::$(composer config cache-files-dir)" @@ -149,7 +95,6 @@ jobs: run: composer install --no-interaction --prefer-dist - name: Execute Tests env: - DB: mariadb - MARIADB: ${{ matrix.configs.mariadb-version }} + DB: sqlite run: | - vendor/bin/phpunit tests/Migrations/MySQL + vendor/bin/phpunit --group driver-sqlite --colors=always diff --git a/.gitignore b/.gitignore index 7182f88..182a270 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ build/logs/* build/ *.db clover.xml -clover.json \ No newline at end of file +clover.json +.phpunit.result.cache diff --git a/composer.json b/composer.json index ec2f14c..ee32768 100644 --- a/composer.json +++ b/composer.json @@ -1,36 +1,47 @@ { - "name": "spiral/migrations", - "type": "library", - "description": "Database migrations, migration scaffolding", - "license": "MIT", - "authors": [ - { - "name": "Anton Titov / Wolfy-J", - "email": "wolfy.jd@gmail.com" - } - ], - "require": { - "php": ">=7.2", - "spiral/core": "^2.7", - "spiral/database": "^2.7", - "spiral/files": "^2.7", - "spiral/tokenizer": "^2.7", - "spiral/reactor": "^2.7" - }, - "require-dev": { - "phpunit/phpunit": "~8.0", - "mockery/mockery": "^1.1", - "spiral/code-style": "^1.0" - }, - "autoload": { - "psr-4": { - "Spiral\\Migrations\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Spiral\\Migrations\\Tests\\": "tests/Migrations/", - "Spiral\\Migrations\\Fixtures\\": "tests/Fixtures/" - } - } + "name": "spiral/migrations", + "type": "library", + "description": "Database migrations, migration scaffolding", + "license": "MIT", + "authors": [ + { + "name": "Anton Titov / Wolfy-J", + "email": "wolfy.jd@gmail.com" + } + ], + "require": { + "php": ">=7.2", + "spiral/core": "^2.7", + "spiral/database": "^2.7", + "spiral/files": "^2.7", + "spiral/tokenizer": "^2.7", + "spiral/reactor": "^2.7" + }, + "autoload": { + "psr-4": { + "Spiral\\Migrations\\": "src" + } + }, + "require-dev": { + "vimeo/psalm": "^4.9", + "phpunit/phpunit": "^8.5|^9.0", + "mockery/mockery": "^1.3", + "spiral/code-style": "^1.0" + }, + "autoload-dev": { + "psr-4": { + "Spiral\\Migrations\\Tests\\": "tests/Migrations", + "Spiral\\Migrations\\Fixtures\\": "tests/Fixtures" + } + }, + "extra": { + "branch-alias": { + "dev-master": "2.3.x-dev" + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..7c0333d --- /dev/null +++ b/psalm.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/src/Atomizer/Atomizer.php b/src/Atomizer/Atomizer.php index 72cf7fe..b84e056 100644 --- a/src/Atomizer/Atomizer.php +++ b/src/Atomizer/Atomizer.php @@ -16,14 +16,14 @@ use Spiral\Reactor\Partial\Source; /** - * Atomizer provides ability to convert given AbstractTables and their changes into set of - * migration commands. + * Atomizer provides ability to convert given AbstractTables and their changes + * into set of migration commands. */ final class Atomizer { - /** @var AbstractTable[] */ protected $tables = []; + /** @var RendererInterface */ private $renderer; diff --git a/src/Config/MigrationConfig.php b/src/Config/MigrationConfig.php index 2e0151d..dc8cf13 100644 --- a/src/Config/MigrationConfig.php +++ b/src/Config/MigrationConfig.php @@ -15,8 +15,20 @@ final class MigrationConfig extends InjectableConfig { + /** + * @internal This is an internal config section name. Please, do not use + * this constant. + */ public const CONFIG = 'migration'; + /** + * @param array{directory?: string|null, table?: string|null, safe?: bool|null} $config + */ + public function __construct(array $config = []) + { + parent::__construct($config); + } + /** * Migrations directory. * diff --git a/src/FileRepository.php b/src/FileRepository.php index ac8c4a3..cfd481a 100644 --- a/src/FileRepository.php +++ b/src/FileRepository.php @@ -17,6 +17,7 @@ use Spiral\Files\FilesInterface; use Spiral\Migrations\Config\MigrationConfig; use Spiral\Migrations\Exception\RepositoryException; +use Spiral\Migrations\Migration\State; use Spiral\Tokenizer\Reflection\ReflectionFile; /** diff --git a/src/Migration.php b/src/Migration.php index 7830cb8..03a4eb6 100644 --- a/src/Migration.php +++ b/src/Migration.php @@ -14,6 +14,9 @@ use Spiral\Database\Database; use Spiral\Database\DatabaseInterface; use Spiral\Migrations\Exception\MigrationException; +use Spiral\Migrations\Migration\DefinitionInterface; +use Spiral\Migrations\Migration\ProvidesSyncStateInterface; +use Spiral\Migrations\Migration\State; /** * Simple migration class with shortcut for database and blueprint instances. @@ -30,7 +33,7 @@ abstract class Migration implements MigrationInterface private $capsule; /** - * {@inheritdoc} + * {@inheritDoc} */ public function getDatabase(): ?string { @@ -40,7 +43,7 @@ public function getDatabase(): ?string /** * {@inheritdoc} */ - public function withCapsule(CapsuleInterface $capsule): MigrationInterface + public function withCapsule(CapsuleInterface $capsule): DefinitionInterface { $migration = clone $this; $migration->capsule = $capsule; @@ -51,7 +54,7 @@ public function withCapsule(CapsuleInterface $capsule): MigrationInterface /** * {@inheritdoc} */ - public function withState(State $state): MigrationInterface + public function withState(State $state): ProvidesSyncStateInterface { $migration = clone $this; $migration->state = $state; diff --git a/src/Migration/DefinitionInterface.php b/src/Migration/DefinitionInterface.php new file mode 100644 index 0000000..e9d514b --- /dev/null +++ b/src/Migration/DefinitionInterface.php @@ -0,0 +1,50 @@ +name = $name; + $this->status = $status; + $this->createdAt = $createdAt; + $this->executedAt = $executedAt; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Migration status. + * + * @return int + */ + public function getStatus(): int + { + return $this->status; + } + + /** + * Get migration creation time. + * + * @return \DateTimeInterface + */ + public function getTimeCreated(): \DateTimeInterface + { + return $this->createdAt; + } + + /** + * Get migration execution time if any. + * + * @return \DateTimeInterface|null + */ + public function getTimeExecuted(): ?\DateTimeInterface + { + return $this->executedAt; + } + + /** + * @param StatusEnum $status + * @param \DateTimeInterface|null $executedAt + * @return State + */ + public function withStatus(int $status, \DateTimeInterface $executedAt = null): State + { + $state = clone $this; + $state->status = $status; + $state->executedAt = $executedAt; + + return $state; + } +} diff --git a/src/Migration/Status.php b/src/Migration/Status.php new file mode 100644 index 0000000..2940e24 --- /dev/null +++ b/src/Migration/Status.php @@ -0,0 +1,42 @@ +dbal->getDatabases() as $db) { + $databases = $this->getDatabases(); + + foreach ($databases as $db) { if (!$this->checkMigrationTableStructure($db)) { return false; } } - return !$this->isRestoreMigrationDataRequired(); + return !$this->isRestoreMigrationDataRequired($databases); } /** - * Configure all related databases with migration table. + * {@inheritDoc} */ public function configure(): void { @@ -93,39 +89,60 @@ public function configure(): void return; } - foreach ($this->dbal->getDatabases() as $db) { - $schema = $db->table($this->config->getTable())->getSchema(); - - // Schema update will automatically sync all needed data - $schema->primary('id'); - $schema->string('migration', 191)->nullable(false); - $schema->datetime('time_executed')->datetime(); - $schema->datetime('created_at')->datetime(); - $schema->index(['migration', 'created_at']) - ->unique(true); + $databases = $this->getDatabases(); - if ($schema->hasIndex(['migration'])) { - $schema->dropIndex(['migration']); - } - - $schema->save(); + foreach ($databases as $database) { + $this->createMigrationTable($database); } - if ($this->isRestoreMigrationDataRequired()) { + if ($this->isRestoreMigrationDataRequired($databases)) { $this->restoreMigrationData(); } } + /** + * Get all databases for which there are migrations. + * + * @return array + */ + private function getDatabases(): array + { + $result = []; + + foreach ($this->repository->getMigrations() as $migration) { + $database = $this->dbal->database($migration->getDatabase()); + + if (! isset($result[$database->getName()])) { + $result[$database->getName()] = $database; + } + } + + return $result; + } + + /** + * Create migration table inside given database + * + * @param Database $database + */ + private function createMigrationTable(Database $database): void + { + $table = new MigrationsTable($database, $this->config->getTable()); + $table->actualize(); + } + /** * Get every available migration with valid meta information. * * @return MigrationInterface[] + * @throws \Exception */ public function getMigrations(): array { $result = []; + foreach ($this->repository->getMigrations() as $migration) { - //Populating migration state and execution time (if any) + // Populating migration state and execution time (if any) $result[] = $migration->withState($this->resolveState($migration)); } @@ -133,22 +150,18 @@ public function getMigrations(): array } /** - * Execute one migration and return it's instance. - * - * @param CapsuleInterface $capsule - * - * @return null|MigrationInterface - * - * @throws MigrationException + * {@inheritDoc} */ public function run(CapsuleInterface $capsule = null): ?MigrationInterface { if (!$this->isConfigured()) { - throw new MigrationException('Unable to run migration, Migrator not configured'); + $this->configure(); } foreach ($this->getMigrations() as $migration) { - if ($migration->getState()->getStatus() !== State::STATUS_PENDING) { + $state = $migration->getState(); + + if ($state->getStatus() !== Status::STATUS_PENDING) { continue; } @@ -162,7 +175,7 @@ static function () use ($migration, $capsule): void { $this->migrationTable($migration->getDatabase())->insertOne( [ - 'migration' => $migration->getState()->getName(), + 'migration' => $state->getName(), 'time_executed' => new \DateTime('now'), 'created_at' => $this->getMigrationCreatedAtForDb($migration), ] @@ -170,17 +183,18 @@ static function () use ($migration, $capsule): void { return $migration->withState($this->resolveState($migration)); } catch (\Throwable $exception) { + $state = $migration->getState(); throw new MigrationException( \sprintf( 'Error in the migration (%s) occurred: %s', \sprintf( '%s (%s)', - $migration->getState()->getName(), - $migration->getState()->getTimeCreated()->format(self::DB_DATE_FORMAT) + $state->getName(), + $state->getTimeCreated()->format(self::DB_DATE_FORMAT) ), $exception->getMessage() ), - $exception->getCode(), + (int)$exception->getCode(), $exception ); } @@ -190,22 +204,19 @@ static function () use ($migration, $capsule): void { } /** - * Rollback last migration and return it's instance. - * - * @param CapsuleInterface $capsule - * @return null|MigrationInterface - * + * @param CapsuleInterface|null $capsule + * @return MigrationInterface|null * @throws \Throwable */ public function rollback(CapsuleInterface $capsule = null): ?MigrationInterface { if (!$this->isConfigured()) { - throw new MigrationException('Unable to run migration, Migrator not configured'); + $this->configure(); } /** @var MigrationInterface $migration */ - foreach (array_reverse($this->getMigrations()) as $migration) { - if ($migration->getState()->getStatus() !== State::STATUS_EXECUTED) { + foreach (\array_reverse($this->getMigrations()) as $migration) { + if ($migration->getState()->getStatus() !== Status::STATUS_EXECUTED) { continue; } @@ -235,19 +246,20 @@ static function () use ($migration, $capsule): void { * * @param MigrationInterface $migration * @return State + * @throws \Exception */ - protected function resolveState(MigrationInterface $migration): State + private function resolveState(MigrationInterface $migration): State { $db = $this->dbal->database($migration->getDatabase()); $data = $this->fetchMigrationData($migration); if (empty($data['time_executed'])) { - return $migration->getState()->withStatus(State::STATUS_PENDING); + return $migration->getState()->withStatus(Status::STATUS_PENDING); } return $migration->getState()->withStatus( - State::STATUS_EXECUTED, + Status::STATUS_EXECUTED, new \DateTimeImmutable($data['time_executed'], $db->getDriver()->getTimezone()) ); } @@ -258,30 +270,20 @@ protected function resolveState(MigrationInterface $migration): State * @param string|null $database * @return Table */ - protected function migrationTable(string $database = null): Table + private function migrationTable(string $database = null): Table { return $this->dbal->database($database)->table($this->config->getTable()); } - protected function checkMigrationTableStructure(Database $db): bool + /** + * @param Database $db + * @return bool + */ + private function checkMigrationTableStructure(Database $db): bool { - if (!$db->hasTable($this->config->getTable())) { - return false; - } - - $table = $db->table($this->config->getTable())->getSchema(); - - foreach (self::MIGRATION_TABLE_FIELDS_LIST as $field) { - if (!$table->hasColumn($field)) { - return false; - } - } + $table = new MigrationsTable($db, $this->config->getTable()); - if (!$table->hasIndex(['migration', 'created_at'])) { - return false; - } - - return true; + return $table->isPresent(); } /** @@ -291,7 +293,7 @@ protected function checkMigrationTableStructure(Database $db): bool * * @return array|null */ - protected function fetchMigrationData(MigrationInterface $migration): ?array + private function fetchMigrationData(MigrationInterface $migration): ?array { $migrationData = $this->migrationTable($migration->getDatabase()) ->select('id', 'time_executed', 'created_at') @@ -307,7 +309,17 @@ protected function fetchMigrationData(MigrationInterface $migration): ?array return is_array($migrationData) ? $migrationData : []; } - protected function restoreMigrationData(): void + /** + * This method updates the state of the empty (null) "created_at" fields for + * each entry in the migration table within the + * issue {@link https://github.com/spiral/migrations/issues/13}. + * + * TODO It is worth noting that this method works in an extremely suboptimal + * way and requires optimizations. + * + * @return void + */ + private function restoreMigrationData(): void { foreach ($this->repository->getMigrations() as $migration) { $migrationData = $this->migrationTable($migration->getDatabase()) @@ -333,13 +345,17 @@ protected function restoreMigrationData(): void } /** - * Check if some data modification required + * Check if some data modification required. + * + * This method checks for empty (null) "created_at" fields created within + * the issue {@link https://github.com/spiral/migrations/issues/13}. * + * @param iterable $databases * @return bool */ - protected function isRestoreMigrationDataRequired(): bool + private function isRestoreMigrationDataRequired(iterable $databases): bool { - foreach ($this->dbal->getDatabases() as $db) { + foreach ($databases as $db) { $table = $db->table($this->config->getTable()); if ( @@ -354,14 +370,26 @@ protected function isRestoreMigrationDataRequired(): bool return false; } - protected function getMigrationCreatedAtForDb(MigrationInterface $migration): \DateTimeInterface + /** + * Creates a new date object based on the database timezone and the + * migration creation date. + * + * @param MigrationInterface $migration + * @return \DateTimeInterface + */ + private function getMigrationCreatedAtForDb(MigrationInterface $migration): \DateTimeInterface { $db = $this->dbal->database($migration->getDatabase()); - return \DateTimeImmutable::createFromFormat( - self::DB_DATE_FORMAT, - $migration->getState()->getTimeCreated()->format(self::DB_DATE_FORMAT), - $db->getDriver()->getTimezone() - ); + $createdAt = $migration->getState() + ->getTimeCreated() + ->format(self::DB_DATE_FORMAT) + ; + + $timezone = $db->getDriver() + ->getTimezone() + ; + + return \DateTimeImmutable::createFromFormat(self::DB_DATE_FORMAT, $createdAt, $timezone); } } diff --git a/src/Migrator/MigrationsTable.php b/src/Migrator/MigrationsTable.php new file mode 100644 index 0000000..11a8e94 --- /dev/null +++ b/src/Migrator/MigrationsTable.php @@ -0,0 +1,166 @@ + + */ + private const MIGRATION_TABLE_FIELDS = [ + 'id', + 'migration', + 'time_executed', + 'created_at' + ]; + + /** + * List of indices in the migration table. An implementation is specified in + * the {@see MigrationsTable::actualize()} method. + * + * @var array + */ + private const MIGRATION_TABLE_INDICES = [ + 'migration', + 'created_at' + ]; + + /** + * @var Database + */ + private $db; + + /** + * @var string + */ + private $name; + + /** + * @var AbstractTable + */ + private $schema; + + /** + * @param Database $db + * @param string $name + */ + public function __construct(Database $db, string $name) + { + $this->db = $db; + $this->name = $name; + + $table = $db->table($name); + $this->schema = $table->getSchema(); + } + + /** + * Schema update will automatically sync all needed data. + * + * Please note that if you change this migration, you will also need to + * change the list of fields in this migration specified in + * the {@see MigrationsTable::MIGRATION_TABLE_FIELDS} + * and {@see MigrationsTable::MIGRATION_TABLE_INDICES} constants. + * + * @return void + */ + public function actualize(): void + { + $this->schema->primary('id'); + $this->schema->string('migration', 191)->nullable(false); + $this->schema->datetime('time_executed')->datetime(); + $this->schema->datetime('created_at')->datetime(); + + $this->schema->index(['migration', 'created_at']) + ->unique(true); + + if ($this->schema->hasIndex(['migration'])) { + $this->schema->dropIndex(['migration']); + } + + $this->schema->save(); + } + + /** + * Returns {@see true} if the migration table in the database is up to date + * or {@see false} instead. + * + * @return bool + */ + public function isPresent(): bool + { + if (!$this->isTableExists()) { + return false; + } + + if (!$this->isNecessaryColumnsExists()) { + return false; + } + + if (!$this->isNecessaryIndicesExists()) { + return false; + } + + return true; + } + + /** + * Returns {@see true} if the migration's table exists or {@see false} + * instead. + * + * @return bool + */ + private function isTableExists(): bool + { + return $this->db->hasTable($this->name); + } + + /** + * Returns {@see true} if all migration's fields is present or {@see false} + * otherwise. + * + * @return bool + */ + private function isNecessaryColumnsExists(): bool + { + foreach (self::MIGRATION_TABLE_FIELDS as $field) { + if (!$this->schema->hasColumn($field)) { + return false; + } + } + + return true; + } + + /** + * Returns {@see true} if all migration's indices is present or {@see false} + * otherwise. + * + * @return bool + */ + private function isNecessaryIndicesExists(): bool + { + return $this->schema->hasIndex(self::MIGRATION_TABLE_INDICES); + } +} diff --git a/src/MigratorInterface.php b/src/MigratorInterface.php new file mode 100644 index 0000000..3bcd226 --- /dev/null +++ b/src/MigratorInterface.php @@ -0,0 +1,43 @@ +name = $name; - $this->status = $status; - $this->timeCreated = $timeCreated; - $this->timeExecuted = $timeExecuted; - } + public const STATUS_UNDEFINED = Status::STATUS_UNDEFINED; /** - * @return string + * @deprecated Please use {@see Status::STATUS_PENDING} instead. + * @var StatusEnum */ - public function getName(): string - { - return $this->name; - } + public const STATUS_PENDING = Status::STATUS_PENDING; /** - * Migration status. - * - * @return int + * @deprecated Please use {@see Status::STATUS_EXECUTED} instead. + * @var StatusEnum */ - public function getStatus(): int - { - return $this->status; - } - - /** - * Get migration creation time. - * - * @return \DateTimeInterface - */ - public function getTimeCreated(): \DateTimeInterface - { - return $this->timeCreated; - } - - /** - * Get migration execution time if any. - * - * @return \DateTimeInterface|null - */ - public function getTimeExecuted(): ?\DateTimeInterface - { - return $this->timeExecuted; - } - - /** - * @param int $status - * @param \DateTimeInterface|null $timeExecuted - * - * @return State - */ - public function withStatus(int $status, \DateTimeInterface $timeExecuted = null): State - { - $state = clone $this; - $state->status = $status; - $state->timeExecuted = $timeExecuted; - - return $state; - } + public const STATUS_EXECUTED = Status::STATUS_EXECUTED; } diff --git a/src/TableBlueprint.php b/src/TableBlueprint.php index e762e3a..1eb0e95 100644 --- a/src/TableBlueprint.php +++ b/src/TableBlueprint.php @@ -22,7 +22,7 @@ final class TableBlueprint { /** @var CapsuleInterface */ - private $capsule = null; + private $capsule; /** @var bool */ private $executed = false; diff --git a/tests/Migrations/AtomizerTest.php b/tests/Migrations/AtomizerTest.php index 5f8bb59..fbf5966 100644 --- a/tests/Migrations/AtomizerTest.php +++ b/tests/Migrations/AtomizerTest.php @@ -12,7 +12,7 @@ namespace Spiral\Migrations\Tests; use Spiral\Migrations\Migration; -use Spiral\Migrations\State; +use Spiral\Migrations\Migration\State; abstract class AtomizerTest extends BaseTest { @@ -30,7 +30,7 @@ public function testCreateAndDiff(): void $migration = $this->migrator->run(); $this->assertInstanceOf(Migration::class, $migration); - $this->assertSame(State::STATUS_EXECUTED, $migration->getState()->getStatus()); + $this->assertSame(Migration\Status::STATUS_EXECUTED, $migration->getState()->getStatus()); $this->assertInstanceOf(\DateTimeInterface::class, $migration->getState()->getTimeCreated()); $this->assertInstanceOf(\DateTimeInterface::class, $migration->getState()->getTimeExecuted()); diff --git a/tests/Migrations/BaseTest.php b/tests/Migrations/BaseTest.php index 09959b4..478c959 100644 --- a/tests/Migrations/BaseTest.php +++ b/tests/Migrations/BaseTest.php @@ -72,7 +72,7 @@ public function setUp(): void echo "\n\n-------- BEGIN: " . $this->getName() . " --------------\n\n"; } - $this->container = $container = new Container(); + $this->container = new Container(); $this->dbal = $this->getDBAL($this->container); $this->migrationConfig = new MigrationConfig(static::CONFIG); diff --git a/tests/Migrations/MigratorTest.php b/tests/Migrations/MigratorTest.php index fcb376d..412b48c 100644 --- a/tests/Migrations/MigratorTest.php +++ b/tests/Migrations/MigratorTest.php @@ -13,7 +13,7 @@ use Spiral\Migrations\Capsule; use Spiral\Migrations\Exception\MigrationException; -use Spiral\Migrations\State; +use Spiral\Migrations\Migration\State; abstract class MigratorTest extends BaseTest { @@ -45,25 +45,42 @@ static function ($migration) { $this->assertSame(['A1', 'A2', 'A3', 'A4', 'B1', 'B2', 'B3', 'C'], $classes); } - public function testIsConfigured(): void + private function generateMigration(string $file, string $class): string { - $this->assertFalse($this->migrator->isConfigured()); + $out = __DIR__ . '/../files/' . $file; + + file_put_contents($out, sprintf(file_get_contents(__DIR__ . '/../files/migration.stub'), $class)); + + return $out; + } + public function testDoNotCreateTableWithoutMigrations(): void + { $this->migrator->configure(); + + $this->assertFalse($this->db->hasTable('migrations')); + } + + public function testIsConfiguredWithoutMigrations(): void + { $this->assertTrue($this->migrator->isConfigured()); } - public function testConfigure(): void + public function testIsConfiguredWithMigrations(): void { + $this->generateMigration('20200909.024119_333_333_migration_1.php', 'A3'); + $this->assertFalse($this->migrator->isConfigured()); $this->migrator->configure(); - $this->assertTrue($this->db->hasTable('migrations')); + $this->assertTrue($this->migrator->isConfigured()); } //no errors expected public function testConfigureTwice(): void { + $this->generateMigration('20200909.024119_333_333_migration_1.php', 'A3'); + $this->assertFalse($this->migrator->isConfigured()); $this->migrator->configure(); @@ -74,6 +91,8 @@ public function testConfigureTwice(): void public function testConfiguredTableStructure(): void { + $this->generateMigration('20200909.024119_333_333_migration_1.php', 'A3'); + $this->migrator->configure(); $table = $this->db->table('migrations'); @@ -102,22 +121,6 @@ public function testConfig(): void $this->assertSame($this->migrationConfig, $this->migrator->getConfig()); } - public function testRunUnconfigured(): void - { - $this->expectException(MigrationException::class); - $this->expectExceptionMessage("Unable to run migration, Migrator not configured"); - - $this->migrator->run(); - } - - public function testRollbackUnconfigured(): void - { - $this->expectException(MigrationException::class); - $this->expectExceptionMessage("Unable to run migration, Migrator not configured"); - - $this->migrator->rollback(); - } - public function testCapsule(): void { $capsule = new Capsule($this->db); @@ -134,9 +137,7 @@ public function testCapsuleException(): void $this->expectException(\Spiral\Migrations\Exception\CapsuleException::class); $capsule = new Capsule($this->db); - $capsule->execute([ - $this - ]); + $capsule->execute([$this]); } public function testNoState(): void diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 3105bfd..9c726a3 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: sqlserver: - image: microsoft/mssql-server-linux + image: mcr.microsoft.com/mssql/server:2019-latest ports: - "11433:1433" environment: diff --git a/tests/files/.gitignore b/tests/files/.gitignore new file mode 100644 index 0000000..cde8069 --- /dev/null +++ b/tests/files/.gitignore @@ -0,0 +1 @@ +*.php