From 3719eb00bfca1dd18eb0172f595664c50a24c6bb Mon Sep 17 00:00:00 2001 From: Mark Story Date: Sun, 14 Jan 2024 23:05:52 -0500 Subject: [PATCH 1/3] Import Environment and tests. There are several incomplete tests right now because migrations use phinx interfaces (and will need to for quite a while), I've not yet been able to create the shim code that bridges between migrations -> phinx interfaces. --- src/Migration/Environment.php | 402 +++++++++++++++++++ tests/TestCase/Migration/EnvironmentTest.php | 337 ++++++++++++++++ 2 files changed, 739 insertions(+) create mode 100644 src/Migration/Environment.php create mode 100644 tests/TestCase/Migration/EnvironmentTest.php diff --git a/src/Migration/Environment.php b/src/Migration/Environment.php new file mode 100644 index 00000000..5123276d --- /dev/null +++ b/src/Migration/Environment.php @@ -0,0 +1,402 @@ + + */ + protected array $options; + + /** + * @var \Symfony\Component\Console\Input\InputInterface|null + */ + protected ?InputInterface $input = null; + + /** + * @var \Symfony\Component\Console\Output\OutputInterface|null + */ + protected ?OutputInterface $output = null; + + /** + * @var int + */ + protected int $currentVersion; + + /** + * @var string + */ + protected string $schemaTableName = 'phinxlog'; + + /** + * @var \Migrations\Db\Adapter\AdapterInterface + */ + protected AdapterInterface $adapter; + + /** + * @param string $name Environment Name + * @param array $options Options + */ + public function __construct(string $name, array $options) + { + $this->name = $name; + $this->options = $options; + } + + /** + * Executes the specified migration on this environment. + * + * @param \Phinx\Migration\MigrationInterface $migration Migration + * @param string $direction Direction + * @param bool $fake flag that if true, we just record running the migration, but not actually do the migration + * @return void + */ + public function executeMigration(MigrationInterface $migration, string $direction = MigrationInterface::UP, bool $fake = false): void + { + $direction = $direction === MigrationInterface::UP ? MigrationInterface::UP : MigrationInterface::DOWN; + $migration->setMigratingUp($direction === MigrationInterface::UP); + + $startTime = time(); + // Need to get a phinx interface adapter here. We will need to have a shim + // to bridge the interfaces. Changing the MigrationInterface is tricky + // because of the method names. + $migration->setAdapter($this->getAdapter()); + + $migration->preFlightCheck(); + + if (method_exists($migration, MigrationInterface::INIT)) { + $migration->{MigrationInterface::INIT}(); + } + + // begin the transaction if the adapter supports it + if ($this->getAdapter()->hasTransactions()) { + $this->getAdapter()->beginTransaction(); + } + + if (!$fake) { + // Run the migration + if (method_exists($migration, MigrationInterface::CHANGE)) { + if ($direction === MigrationInterface::DOWN) { + // Create an instance of the ProxyAdapter so we can record all + // of the migration commands for reverse playback + + /** @var \Phinx\Db\Adapter\ProxyAdapter $proxyAdapter */ + $proxyAdapter = AdapterFactory::instance() + ->getWrapper('proxy', $this->getAdapter()); + $migration->setAdapter($proxyAdapter); + $migration->{MigrationInterface::CHANGE}(); + $proxyAdapter->executeInvertedCommands(); + $migration->setAdapter($this->getAdapter()); + } else { + $migration->{MigrationInterface::CHANGE}(); + } + } else { + $migration->{$direction}(); + } + } + + // Record it in the database + $this->getAdapter()->migrated($migration, $direction, date('Y-m-d H:i:s', $startTime), date('Y-m-d H:i:s', time())); + + // commit the transaction if the adapter supports it + if ($this->getAdapter()->hasTransactions()) { + $this->getAdapter()->commitTransaction(); + } + + $migration->postFlightCheck(); + } + + /** + * Executes the specified seeder on this environment. + * + * @param \Phinx\Seed\SeedInterface $seed Seed + * @return void + */ + public function executeSeed(SeedInterface $seed): void + { + $seed->setAdapter($this->getAdapter()); + if (method_exists($seed, SeedInterface::INIT)) { + $seed->{SeedInterface::INIT}(); + } + + // begin the transaction if the adapter supports it + if ($this->getAdapter()->hasTransactions()) { + $this->getAdapter()->beginTransaction(); + } + + // Run the seeder + if (method_exists($seed, SeedInterface::RUN)) { + $seed->{SeedInterface::RUN}(); + } + + // commit the transaction if the adapter supports it + if ($this->getAdapter()->hasTransactions()) { + $this->getAdapter()->commitTransaction(); + } + } + + /** + * Sets the environment's name. + * + * @param string $name Environment Name + * @return $this + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Gets the environment name. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Sets the environment's options. + * + * @param array $options Environment Options + * @return $this + */ + public function setOptions(array $options) + { + $this->options = $options; + + return $this; + } + + /** + * Gets the environment's options. + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Sets the console input. + * + * @param \Symfony\Component\Console\Input\InputInterface $input Input + * @return $this + */ + public function setInput(InputInterface $input) + { + $this->input = $input; + + return $this; + } + + /** + * Gets the console input. + * + * @return \Symfony\Component\Console\Input\InputInterface|null + */ + public function getInput(): ?InputInterface + { + return $this->input; + } + + /** + * Sets the console output. + * + * @param \Symfony\Component\Console\Output\OutputInterface $output Output + * @return $this + */ + public function setOutput(OutputInterface $output) + { + $this->output = $output; + + return $this; + } + + /** + * Gets the console output. + * + * @return \Symfony\Component\Console\Output\OutputInterface|null + */ + public function getOutput(): ?OutputInterface + { + return $this->output; + } + + /** + * Gets all migrated version numbers. + * + * @return array + */ + public function getVersions(): array + { + return $this->getAdapter()->getVersions(); + } + + /** + * Get all migration log entries, indexed by version creation time and sorted in ascending order by the configuration's + * version_order option + * + * @return array + */ + public function getVersionLog(): array + { + return $this->getAdapter()->getVersionLog(); + } + + /** + * Sets the current version of the environment. + * + * @param int $version Environment Version + * @return $this + */ + public function setCurrentVersion(int $version) + { + $this->currentVersion = $version; + + return $this; + } + + /** + * Gets the current version of the environment. + * + * @return int + */ + public function getCurrentVersion(): int + { + // We don't cache this code as the current version is pretty volatile. + // that means they're no point in a setter then? + // maybe we should cache and call a reset() method every time a migration is run + $versions = $this->getVersions(); + $version = 0; + + if (!empty($versions)) { + $version = end($versions); + } + + $this->setCurrentVersion($version); + + return $this->currentVersion; + } + + /** + * Sets the database adapter. + * + * @param \Migrations\Db\Adapter\AdapterInterface $adapter Database Adapter + * @return $this + */ + public function setAdapter(AdapterInterface $adapter) + { + $this->adapter = $adapter; + + return $this; + } + + /** + * Gets the database adapter. + * + * @throws \RuntimeException + * @return \Migrations\Db\Adapter\AdapterInterface + */ + public function getAdapter(): AdapterInterface + { + if (isset($this->adapter)) { + return $this->adapter; + } + + $options = $this->getOptions(); + if (isset($options['connection'])) { + if (!($options['connection'] instanceof PDO)) { + throw new RuntimeException('The specified connection is not a PDO instance'); + } + + $options['connection']->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $options['adapter'] = $options['connection']->getAttribute(PDO::ATTR_DRIVER_NAME); + } + if (!isset($options['adapter'])) { + throw new RuntimeException('No adapter was specified for environment: ' . $this->getName()); + } + + $factory = AdapterFactory::instance(); + $adapter = $factory + ->getAdapter($options['adapter'], $options); + + // Automatically time the executed commands + $adapter = $factory->getWrapper('timed', $adapter); + + if (isset($options['wrapper'])) { + $adapter = $factory + ->getWrapper($options['wrapper'], $adapter); + } + + /** @var \Symfony\Component\Console\Input\InputInterface|null $input */ + $input = $this->getInput(); + if ($input) { + $adapter->setInput($this->getInput()); + } + + /** @var \Symfony\Component\Console\Output\OutputInterface|null $output */ + $output = $this->getOutput(); + if ($output) { + $adapter->setOutput($this->getOutput()); + } + + // Use the TablePrefixAdapter if table prefix/suffixes are in use + if ($adapter->hasOption('table_prefix') || $adapter->hasOption('table_suffix')) { + $adapter = AdapterFactory::instance() + ->getWrapper('prefix', $adapter); + } + + $this->setAdapter($adapter); + + return $adapter; + } + + /** + * Sets the schema table name. + * + * @param string $schemaTableName Schema Table Name + * @return $this + */ + public function setSchemaTableName(string $schemaTableName) + { + $this->schemaTableName = $schemaTableName; + + return $this; + } + + /** + * Gets the schema table name. + * + * @return string + */ + public function getSchemaTableName(): string + { + return $this->schemaTableName; + } +} diff --git a/tests/TestCase/Migration/EnvironmentTest.php b/tests/TestCase/Migration/EnvironmentTest.php new file mode 100644 index 00000000..60b5994a --- /dev/null +++ b/tests/TestCase/Migration/EnvironmentTest.php @@ -0,0 +1,337 @@ +environment = new Environment('test', []); + } + + public function testConstructorWorksAsExpected() + { + $env = new Environment('testenv', ['foo' => 'bar']); + $this->assertEquals('testenv', $env->getName()); + $this->assertArrayHasKey('foo', $env->getOptions()); + } + + public function testSettingTheName() + { + $this->environment->setName('prod123'); + $this->assertEquals('prod123', $this->environment->getName()); + } + + public function testSettingOptions() + { + $this->environment->setOptions(['foo' => 'bar']); + $this->assertArrayHasKey('foo', $this->environment->getOptions()); + } + + public function testInvalidAdapter() + { + $this->environment->setOptions(['adapter' => 'fakeadapter']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Adapter "fakeadapter" has not been registered'); + + $this->environment->getAdapter(); + } + + public function testNoAdapter() + { + $this->expectException(RuntimeException::class); + + $this->environment->getAdapter(); + } + + private function getPdoMock() + { + $pdoMock = $this->getMockBuilder(PDO::class)->disableOriginalConstructor()->getMock(); + $attributes = []; + $pdoMock->method('setAttribute')->will($this->returnCallback(function ($attribute, $value) use (&$attributes) { + $attributes[$attribute] = $value; + + return true; + })); + $pdoMock->method('getAttribute')->will($this->returnCallback(function ($attribute) use (&$attributes) { + return $attributes[$attribute] ?? 'pdomock'; + })); + + return $pdoMock; + } + + public function testGetAdapterWithExistingPdoInstance() + { + $this->markTestIncomplete('Requires a shim adapter to pass.'); + $adapter = $this->getMockForAbstractClass('\Migrations\Db\Adapter\PdoAdapter', [['foo' => 'bar']]); + AdapterFactory::instance()->registerAdapter('pdomock', $adapter); + $this->environment->setOptions(['connection' => $this->getPdoMock()]); + $options = $this->environment->getAdapter()->getOptions(); + $this->assertEquals('pdomock', $options['adapter']); + } + + public function testSetPdoAttributeToErrmodeException() + { + $this->markTestIncomplete('Requires a shim adapter to pass.'); + $adapter = $this->getMockForAbstractClass('\Migrations\Db\Adapter\PdoAdapter', [['foo' => 'bar']]); + AdapterFactory::instance()->registerAdapter('pdomock', $adapter); + $this->environment->setOptions(['connection' => $this->getPdoMock()]); + $options = $this->environment->getAdapter()->getOptions(); + $this->assertEquals(PDO::ERRMODE_EXCEPTION, $options['connection']->getAttribute(PDO::ATTR_ERRMODE)); + } + + public function testGetAdapterWithBadExistingPdoInstance() + { + $this->environment->setOptions(['connection' => new stdClass()]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The specified connection is not a PDO instance'); + + $this->environment->getAdapter(); + } + + public function testSchemaName() + { + $this->assertEquals('phinxlog', $this->environment->getSchemaTableName()); + + $this->environment->setSchemaTableName('changelog'); + $this->assertEquals('changelog', $this->environment->getSchemaTableName()); + } + + public function testCurrentVersion() + { + $stub = $this->getMockBuilder('\Migrations\Db\Adapter\PdoAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $stub->expects($this->any()) + ->method('getVersions') + ->will($this->returnValue([20110301080000])); + + $this->environment->setAdapter($stub); + + $this->assertEquals(20110301080000, $this->environment->getCurrentVersion()); + } + + public function testExecutingAMigrationUp() + { + // stub adapter + $adapterStub = $this->getMockBuilder('\Migrations\Db\Adapter\PdoAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('migrated') + ->willReturn($adapterStub); + + $this->environment->setAdapter($adapterStub); + + // up + $upMigration = $this->getMockBuilder('\Phinx\Migration\AbstractMigration') + ->setConstructorArgs(['mockenv', '20110301080000']) + ->addMethods(['up']) + ->getMock(); + $upMigration->expects($this->once()) + ->method('up'); + + $this->markTestIncomplete('Requires a shim adapter to pass.'); + $this->environment->executeMigration($upMigration, MigrationInterface::UP); + } + + public function testExecutingAMigrationDown() + { + // stub adapter + $adapterStub = $this->getMockBuilder('\Migrations\Db\Adapter\PdoAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('migrated') + ->willReturn($adapterStub); + + $this->environment->setAdapter($adapterStub); + + // down + $downMigration = $this->getMockBuilder('\Phinx\Migration\AbstractMigration') + ->setConstructorArgs(['mockenv', '20110301080000']) + ->addMethods(['down']) + ->getMock(); + $downMigration->expects($this->once()) + ->method('down'); + + $this->markTestIncomplete('Requires a shim adapter to pass.'); + $this->environment->executeMigration($downMigration, MigrationInterface::DOWN); + } + + public function testExecutingAMigrationWithTransactions() + { + $this->markTestIncomplete('Requires a shim adapter to pass.'); + // stub adapter + $adapterStub = $this->getMockBuilder('\Migrations\Db\Adapter\PdoAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('beginTransaction'); + + $adapterStub->expects($this->once()) + ->method('commitTransaction'); + + $adapterStub->expects($this->exactly(2)) + ->method('hasTransactions') + ->will($this->returnValue(true)); + + $this->environment->setAdapter($adapterStub); + + // migrate + $migration = $this->getMockBuilder('\Phinx\Migration\AbstractMigration') + ->setConstructorArgs(['mockenv', '20110301080000']) + ->addMethods(['up']) + ->getMock(); + $migration->expects($this->once()) + ->method('up'); + + $this->environment->executeMigration($migration, MigrationInterface::UP); + } + + public function testExecutingAChangeMigrationUp() + { + $this->markTestIncomplete('Requires a shim adapter to pass.'); + // stub adapter + $adapterStub = $this->getMockBuilder('\Migrations\Db\Adapter\PdoAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('migrated') + ->willReturn($adapterStub); + + $this->environment->setAdapter($adapterStub); + + // migration + $migration = $this->getMockBuilder('\Phinx\Migration\AbstractMigration') + ->setConstructorArgs(['mockenv', '20130301080000']) + ->addMethods(['change']) + ->getMock(); + $migration->expects($this->once()) + ->method('change'); + + $this->environment->executeMigration($migration, MigrationInterface::UP); + } + + public function testExecutingAChangeMigrationDown() + { + $this->markTestIncomplete('Requires a shim adapter to pass.'); + // stub adapter + $adapterStub = $this->getMockBuilder('\Migrations\Db\Adapter\PdoAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('migrated') + ->willReturn($adapterStub); + + $this->environment->setAdapter($adapterStub); + + // migration + $migration = $this->getMockBuilder('\Phinx\Migration\AbstractMigration') + ->setConstructorArgs(['mockenv', '20130301080000']) + ->addMethods(['change']) + ->getMock(); + $migration->expects($this->once()) + ->method('change'); + + $this->environment->executeMigration($migration, MigrationInterface::DOWN); + } + + public function testExecutingAFakeMigration() + { + $this->markTestIncomplete('Requires a shim adapter to pass.'); + // stub adapter + $adapterStub = $this->getMockBuilder('\Migrations\Db\Adapter\PdoAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('migrated') + ->willReturn($adapterStub); + + $this->environment->setAdapter($adapterStub); + + // migration + $migration = $this->getMockBuilder('\Phinx\Migration\AbstractMigration') + ->setConstructorArgs(['mockenv', '20130301080000']) + ->addMethods(['change']) + ->getMock(); + $migration->expects($this->never()) + ->method('change'); + + $this->environment->executeMigration($migration, MigrationInterface::UP, true); + } + + public function testGettingInputObject() + { + $mock = $this->getMockBuilder('\Symfony\Component\Console\Input\InputInterface') + ->getMock(); + $this->environment->setInput($mock); + $inputObject = $this->environment->getInput(); + $this->assertInstanceOf('\Symfony\Component\Console\Input\InputInterface', $inputObject); + } + + public function testExecuteMigrationCallsInit() + { + $this->markTestIncomplete('Requires a shim adapter to pass.'); + // stub adapter + $adapterStub = $this->getMockBuilder('\Migrations\Db\Adapter\PdoAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + $adapterStub->expects($this->once()) + ->method('migrated') + ->willReturn($adapterStub); + + $this->environment->setAdapter($adapterStub); + + // up + $upMigration = $this->getMockBuilder('\Phinx\Migration\AbstractMigration') + ->setConstructorArgs(['mockenv', '20110301080000']) + ->addMethods(['up', 'init']) + ->getMock(); + $upMigration->expects($this->once()) + ->method('up'); + $upMigration->expects($this->once()) + ->method('init'); + + $this->environment->executeMigration($upMigration, MigrationInterface::UP); + } + + public function testExecuteSeedInit() + { + $this->markTestIncomplete('Requires a shim adapter to pass.'); + // stub adapter + $adapterStub = $this->getMockBuilder('\Migrations\Db\Adapter\PdoAdapter') + ->setConstructorArgs([[]]) + ->getMock(); + + $this->environment->setAdapter($adapterStub); + + // up + $seed = $this->getMockBuilder('\Migrations\AbstractSeed') + ->onlyMethods(['run', 'init']) + ->getMock(); + + $seed->expects($this->once()) + ->method('run'); + $seed->expects($this->once()) + ->method('init'); + + $this->environment->executeSeed($seed); + } +} From f465a3b8ee1fb795488b23c5fb99fb0d714393c9 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Sun, 14 Jan 2024 23:20:07 -0500 Subject: [PATCH 2/3] Fix psalm errors and update baselines --- phpstan-baseline.neon | 10 ++++++++++ psalm-baseline.xml | 13 +++++++++++++ src/Migration/Environment.php | 4 ++-- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2d806152..02394317 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -95,6 +95,16 @@ parameters: count: 2 path: src/Db/Adapter/SqlserverAdapter.php + - + message: "#^Parameter \\#1 \\$adapter of method Phinx\\\\Migration\\\\MigrationInterface\\:\\:setAdapter\\(\\) expects Phinx\\\\Db\\\\Adapter\\\\AdapterInterface, Migrations\\\\Db\\\\Adapter\\\\AdapterInterface given\\.$#" + count: 2 + path: src/Migration/Environment.php + + - + message: "#^Parameter \\#1 \\$adapter of method Phinx\\\\Seed\\\\SeedInterface\\:\\:setAdapter\\(\\) expects Phinx\\\\Db\\\\Adapter\\\\AdapterInterface, Migrations\\\\Db\\\\Adapter\\\\AdapterInterface given\\.$#" + count: 1 + path: src/Migration/Environment.php + - message: "#^Possibly invalid array key type Cake\\\\Database\\\\Schema\\\\TableSchemaInterface\\|string\\.$#" count: 2 diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 4b73c27a..a708988a 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -59,6 +59,9 @@ is_array($newColumns) + + PDO::SQLSRV_ATTR_ENCODING + @@ -66,6 +69,16 @@ $newColumns + + + getAdapter()]]> + getAdapter()]]> + getAdapter()]]> + + + adapter)]]> + + array_merge($versions, array_keys($migrations)) diff --git a/src/Migration/Environment.php b/src/Migration/Environment.php index 5123276d..7d916e50 100644 --- a/src/Migration/Environment.php +++ b/src/Migration/Environment.php @@ -357,13 +357,13 @@ public function getAdapter(): AdapterInterface /** @var \Symfony\Component\Console\Input\InputInterface|null $input */ $input = $this->getInput(); if ($input) { - $adapter->setInput($this->getInput()); + $adapter->setInput($input); } /** @var \Symfony\Component\Console\Output\OutputInterface|null $output */ $output = $this->getOutput(); if ($output) { - $adapter->setOutput($this->getOutput()); + $adapter->setOutput($output); } // Use the TablePrefixAdapter if table prefix/suffixes are in use From fdfe8f4cf7db56c6cc67c510cb9cc14ac5644083 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Sun, 14 Jan 2024 23:58:31 -0500 Subject: [PATCH 3/3] Remove baseline entry --- psalm-baseline.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index a708988a..1796c2b5 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -59,9 +59,6 @@ is_array($newColumns) - - PDO::SQLSRV_ATTR_ENCODING -