diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 7b8f4c30..a12664b1 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -5,6 +5,11 @@ + + + + + diff --git a/src/BaseMigration.php b/src/BaseMigration.php new file mode 100644 index 00000000..4e8fc3f8 --- /dev/null +++ b/src/BaseMigration.php @@ -0,0 +1,483 @@ + + */ + protected array $tables = []; + + /** + * Is migrating up prop + * + * @var bool + */ + protected bool $isMigratingUp = true; + + /** + * Whether the tables created in this migration + * should auto-create an `id` field or not + * + * This option is global for all tables created in the migration file. + * If you set it to false, you have to manually add the primary keys for your + * tables using the Migrations\Table::addPrimaryKey() method + * + * @var bool + */ + public bool $autoId = true; + + /** + * Constructor + * + * @param int $version The version this migration is + */ + public function __construct(protected int $version) + { + $this->validateVersion($this->version); + } + + /** + * {@inheritDoc} + */ + public function setAdapter(AdapterInterface $adapter) + { + $this->adapter = $adapter; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function getAdapter(): AdapterInterface + { + if (!$this->adapter) { + throw new RuntimeException('Adapter not set.'); + } + + return $this->adapter; + } + + /** + * {@inheritDoc} + */ + public function setIo(ConsoleIo $io) + { + $this->io = $io; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function getIo(): ?ConsoleIo + { + return $this->io; + } + + /** + * {@inheritDoc} + */ + public function getConfig(): ?ConfigInterface + { + return $this->config; + } + + /** + * {@inheritDoc} + */ + public function setConfig(ConfigInterface $config) + { + $this->config = $config; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function getName(): string + { + return static::class; + } + + /** + * Sets the migration version number. + * + * @param int $version Version + * @return $this + */ + public function setVersion(int $version) + { + $this->validateVersion($version); + $this->version = $version; + + return $this; + } + + /** + * Gets the migration version number. + * + * @return int + */ + public function getVersion(): int + { + return $this->version; + } + + /** + * Sets whether this migration is being applied or reverted + * + * @param bool $isMigratingUp True if the migration is being applied + * @return $this + */ + public function setMigratingUp(bool $isMigratingUp) + { + $this->isMigratingUp = $isMigratingUp; + + return $this; + } + + /** + * Hook method to decide if this migration should use transactions + * + * By default if your driver supports transactions, a transaction will be opened + * before the migration begins, and commit when the migration completes. + * + * @return bool + */ + public function useTransactions(): bool + { + return $this->getAdapter()->hasTransactions(); + } + + /** + * Gets whether this migration is being applied or reverted. + * True means that the migration is being applied. + * + * @return bool + */ + public function isMigratingUp(): bool + { + return $this->isMigratingUp; + } + + /** + * Executes a SQL statement and returns the number of affected rows. + * + * @param string $sql SQL + * @param array $params parameters to use for prepared query + * @return int + */ + public function execute(string $sql, array $params = []): int + { + return $this->getAdapter()->execute($sql, $params); + } + + /** + * Executes a SQL statement. + * + * The return type depends on the underlying adapter being used. To improve + * IDE auto-completion possibility, you can overwrite the query method + * phpDoc in your (typically custom abstract parent) migration class, where + * you can set the return type by the adapter in your current use. + * + * @param string $sql SQL + * @param array $params parameters to use for prepared query + * @return mixed + */ + public function query(string $sql, array $params = []): mixed + { + return $this->getAdapter()->query($sql, $params); + } + + /** + * Returns a new Query object that can be used to build complex SELECT, UPDATE, INSERT or DELETE + * queries and execute them against the current database. + * + * Queries executed through the query builder are always sent to the database, regardless of the + * the dry-run settings. + * + * @see https://api.cakephp.org/3.6/class-Cake.Database.Query.html + * @param string $type Query + * @return \Cake\Database\Query + */ + public function getQueryBuilder(string $type): Query + { + return $this->getAdapter()->getQueryBuilder($type); + } + + /** + * Returns a new SelectQuery object that can be used to build complex + * SELECT queries and execute them against the current database. + * + * Queries executed through the query builder are always sent to the database, regardless of the + * the dry-run settings. + * + * @return \Cake\Database\Query\SelectQuery + */ + public function getSelectBuilder(): SelectQuery + { + return $this->getAdapter()->getSelectBuilder(); + } + + /** + * Returns a new InsertQuery object that can be used to build complex + * INSERT queries and execute them against the current database. + * + * Queries executed through the query builder are always sent to the database, regardless of the + * the dry-run settings. + * + * @return \Cake\Database\Query\InsertQuery + */ + public function getInsertBuilder(): InsertQuery + { + return $this->getAdapter()->getInsertBuilder(); + } + + /** + * Returns a new UpdateQuery object that can be used to build complex + * UPDATE queries and execute them against the current database. + * + * Queries executed through the query builder are always sent to the database, regardless of the + * the dry-run settings. + * + * @return \Cake\Database\Query\UpdateQuery + */ + public function getUpdateBuilder(): UpdateQuery + { + return $this->getAdapter()->getUpdateBuilder(); + } + + /** + * Returns a new DeleteQuery object that can be used to build complex + * DELETE queries and execute them against the current database. + * + * Queries executed through the query builder are always sent to the database, regardless of the + * the dry-run settings. + * + * @return \Cake\Database\Query\DeleteQuery + */ + public function getDeleteBuilder(): DeleteQuery + { + return $this->getAdapter()->getDeleteBuilder(); + } + + /** + * Executes a query and returns only one row as an array. + * + * @param string $sql SQL + * @return array|false + */ + public function fetchRow(string $sql): array|false + { + return $this->getAdapter()->fetchRow($sql); + } + + /** + * Executes a query and returns an array of rows. + * + * @param string $sql SQL + * @return array + */ + public function fetchAll(string $sql): array + { + return $this->getAdapter()->fetchAll($sql); + } + + /** + * Create a new database. + * + * @param string $name Database Name + * @param array $options Options + * @return void + */ + public function createDatabase(string $name, array $options): void + { + $this->getAdapter()->createDatabase($name, $options); + } + + /** + * Drop a database. + * + * @param string $name Database Name + * @return void + */ + public function dropDatabase(string $name): void + { + $this->getAdapter()->dropDatabase($name); + } + + /** + * Creates schema. + * + * This will thrown an error for adapters that do not support schemas. + * + * @param string $name Schema name + * @return void + * @throws \BadMethodCallException + */ + public function createSchema(string $name): void + { + $this->getAdapter()->createSchema($name); + } + + /** + * Drops schema. + * + * This will thrown an error for adapters that do not support schemas. + * + * @param string $name Schema name + * @return void + * @throws \BadMethodCallException + */ + public function dropSchema(string $name): void + { + $this->getAdapter()->dropSchema($name); + } + + /** + * Checks to see if a table exists. + * + * @param string $tableName Table name + * @return bool + */ + public function hasTable(string $tableName): bool + { + return $this->getAdapter()->hasTable($tableName); + } + + /** + * Returns an instance of the \Table class. + * + * You can use this class to create and manipulate tables. + * + * @param string $tableName Table name + * @param array $options Options + * @return \Migrations\Db\Table + */ + public function table(string $tableName, array $options = []): Table + { + if ($this->autoId === false) { + $options['id'] = false; + } + + $table = new Table($tableName, $options, $this->getAdapter()); + $this->tables[] = $table; + + return $table; + } + + /** + * Perform checks on the migration, printing a warning + * if there are potential problems. + * + * @return void + */ + public function preFlightCheck(): void + { + if (method_exists($this, MigrationInterface::CHANGE)) { + if ( + method_exists($this, MigrationInterface::UP) || + method_exists($this, MigrationInterface::DOWN) + ) { + $io = $this->getIo(); + if ($io) { + $io->out( + 'warning Migration contains both change() and up()/down() methods.' . + ' Ignoring up() and down().' + ); + } + } + } + } + + /** + * Perform checks on the migration after completion + * + * Right now, the only check is whether all changes were committed + * + * @return void + */ + public function postFlightCheck(): void + { + foreach ($this->tables as $table) { + if ($table->hasPendingActions()) { + throw new RuntimeException(sprintf('Migration %s_%s has pending actions after execution!', $this->getVersion(), $this->getName())); + } + } + } + + /** + * {@inheritDoc} + */ + public function shouldExecute(): bool + { + return true; + } + + /** + * Makes sure the version int is within range for valid datetime. + * This is required to have a meaningful order in the overview. + * + * @param int $version Version + * @return void + */ + protected function validateVersion(int $version): void + { + $length = strlen((string)$version); + if ($length === 14) { + return; + } + + throw new RuntimeException('Invalid version `' . $version . '`, should be in format `YYYYMMDDHHMMSS` (length of 14).'); + } +} diff --git a/src/BaseSeed.php b/src/BaseSeed.php index 9ccb8eca..95b60b0b 100644 --- a/src/BaseSeed.php +++ b/src/BaseSeed.php @@ -23,8 +23,25 @@ */ class BaseSeed implements SeedInterface { + /** + * The Adapter instance + * + * @var \Migrations\Db\Adapter\AdapterInterface + */ protected ?AdapterInterface $adapter = null; + + /** + * The ConsoleIo instance + * + * @var \Cake\Console\ConsoleIo + */ protected ?ConsoleIo $io = null; + + /** + * The config instance. + * + * @var \Migrations\Config\ConfigInterface + */ protected ?ConfigInterface $config; /** diff --git a/src/Db/Table.php b/src/Db/Table.php index 30a62003..aff92485 100644 --- a/src/Db/Table.php +++ b/src/Db/Table.php @@ -8,6 +8,7 @@ namespace Migrations\Db; +use Cake\Collection\Collection; use Cake\Core\Configure; use InvalidArgumentException; use Migrations\Db\Action\AddColumn; @@ -61,6 +62,15 @@ class Table */ protected array $data = []; + /** + * Primary key for this table. + * Can either be a string or an array in case of composite + * primary key. + * + * @var string|string[] + */ + protected string|array $primaryKey; + /** * @param string $name Table Name * @param array $options Options @@ -289,6 +299,19 @@ public function reset(): void $this->resetData(); } + /** + * Add a primary key to a database table. + * + * @param string|string[] $columns Table Column(s) + * @return $this + */ + public function addPrimaryKey(string|array $columns) + { + $this->primaryKey = $columns; + + return $this; + } + /** * Add a table column. * @@ -538,10 +561,10 @@ public function hasForeignKey(string|array $columns, ?string $constraint = null) * @param bool $withTimezone Whether to set the timezone option on the added columns * @return $this */ - public function addTimestamps(string|false|null $createdAt = 'created_at', string|false|null $updatedAt = 'updated_at', bool $withTimezone = false) + public function addTimestamps(string|false|null $createdAt = 'created', string|false|null $updatedAt = 'updated', bool $withTimezone = false) { - $createdAt = $createdAt ?? 'created_at'; - $updatedAt = $updatedAt ?? 'updated_at'; + $createdAt = $createdAt ?? 'created'; + $updatedAt = $updatedAt ?? 'updated'; if (!$createdAt && !$updatedAt) { throw new RuntimeException('Cannot set both created_at and updated_at columns to false'); @@ -625,11 +648,90 @@ public function insert(array $data) */ public function create(): void { + $options = $this->getTable()->getOptions(); + if ((!isset($options['id']) || $options['id'] === false) && !empty($this->primaryKey)) { + $options['primary_key'] = (array)$this->primaryKey; + $this->filterPrimaryKey($options); + } + + $adapter = $this->getAdapter(); + if ($adapter->getAdapterType() === 'mysql' && empty($options['collation'])) { + // TODO this should be a method on the MySQL adapter. + // It could be a hook method on the adapter? + $encodingRequest = 'SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME + FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :dbname'; + + $connection = $adapter->getConnection(); + $connectionConfig = $connection->config(); + + $statement = $connection->execute($encodingRequest, ['dbname' => $connectionConfig['database']]); + $defaultEncoding = $statement->fetch('assoc'); + if (!empty($defaultEncoding['DEFAULT_COLLATION_NAME'])) { + $options['collation'] = $defaultEncoding['DEFAULT_COLLATION_NAME']; + } + } + + $this->getTable()->setOptions($options); + $this->executeActions(false); $this->saveData(); $this->reset(); // reset pending changes } + /** + * This method is called in case a primary key was defined using the addPrimaryKey() method. + * It currently does something only if using SQLite. + * If a column is an auto-increment key in SQLite, it has to be a primary key and it has to defined + * when defining the column. Phinx takes care of that so we have to make sure columns defined as autoincrement were + * not added with the addPrimaryKey method, otherwise, SQL queries will be wrong. + * + * @return void + */ + protected function filterPrimaryKey(array $options): void + { + if ($this->getAdapter()->getAdapterType() !== 'sqlite' || empty($options['primary_key'])) { + return; + } + + $primaryKey = $options['primary_key']; + if (!is_array($primaryKey)) { + $primaryKey = [$primaryKey]; + } + $primaryKey = array_flip($primaryKey); + + $columnsCollection = (new Collection($this->actions->getActions())) + ->filter(function ($action) { + return $action instanceof AddColumn; + }) + ->map(function ($action) { + /** @var \Phinx\Db\Action\ChangeColumn|\Phinx\Db\Action\RenameColumn|\Phinx\Db\Action\RemoveColumn|\Phinx\Db\Action\AddColumn $action */ + return $action->getColumn(); + }); + $primaryKeyColumns = $columnsCollection->filter(function (Column $columnDef, $key) use ($primaryKey) { + return isset($primaryKey[$columnDef->getName()]); + })->toArray(); + + if (empty($primaryKeyColumns)) { + return; + } + + foreach ($primaryKeyColumns as $primaryKeyColumn) { + if ($primaryKeyColumn->isIdentity()) { + unset($primaryKey[$primaryKeyColumn->getName()]); + } + } + + $primaryKey = array_flip($primaryKey); + + if (!empty($primaryKey)) { + $options['primary_key'] = $primaryKey; + } else { + unset($options['primary_key']); + } + + $this->getTable()->setOptions($options); + } + /** * Updates a table from the object instance. * diff --git a/src/Db/Table/Column.php b/src/Db/Table/Column.php index 22bdf851..668d911f 100644 --- a/src/Db/Table/Column.php +++ b/src/Db/Table/Column.php @@ -765,6 +765,7 @@ protected function getAliasedOptions(): array return [ 'length' => 'limit', 'precision' => 'limit', + 'autoIncrement' => 'identity', ]; } diff --git a/src/MigrationInterface.php b/src/MigrationInterface.php index 28046a39..8ec7c81f 100644 --- a/src/MigrationInterface.php +++ b/src/MigrationInterface.php @@ -290,7 +290,7 @@ public function hasTable(string $tableName): bool; * @param array $options Options * @return \Migrations\Db\Table */ - public function table(string $tableName, array $options): Table; + public function table(string $tableName, array $options = []): Table; /** * Perform checks on the migration, printing a warning diff --git a/src/Shim/MigrationAdapter.php b/src/Shim/MigrationAdapter.php index ba80f2de..70a3b916 100644 --- a/src/Shim/MigrationAdapter.php +++ b/src/Shim/MigrationAdapter.php @@ -374,7 +374,7 @@ public function hasTable(string $tableName): bool /** * {@inheritDoc} */ - public function table(string $tableName, array $options): Table + public function table(string $tableName, array $options = []): Table { throw new RuntimeException('MigrationAdapter::table is not implemented'); } diff --git a/tests/TestCase/Command/MigrateCommandTest.php b/tests/TestCase/Command/MigrateCommandTest.php index 1950d11c..b474a468 100644 --- a/tests/TestCase/Command/MigrateCommandTest.php +++ b/tests/TestCase/Command/MigrateCommandTest.php @@ -108,6 +108,27 @@ public function testMigrateSourceDefault(): void $this->assertFileExists($dumpFile); } + /** + * Integration test for BaseMigration with built-in backend. + */ + public function testMigrateBaseMigration(): void + { + $migrationPath = ROOT . DS . 'config' . DS . 'BaseMigrations'; + $this->exec('migrations migrate -v --source BaseMigrations -c test --no-lock'); + $this->assertExitSuccess(); + + $this->assertOutputContains('using connection test'); + $this->assertOutputContains('using paths ' . $migrationPath); + $this->assertOutputContains('BaseMigrationTables: migrated'); + $this->assertOutputContains('query=121'); + $this->assertOutputContains('fetchRow=122'); + $this->assertOutputContains('hasTable=1'); + $this->assertOutputContains('All Done'); + + $table = $this->fetchTable('Phinxlog'); + $this->assertCount(1, $table->find()->all()->toArray()); + } + /** * Test that running with a no-op migrations is successful */ diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index f1999989..824b0f4d 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -397,7 +397,7 @@ public function testCreateTableAndInheritDefaultCollation() ->save(); $this->assertTrue($adapter->hasTable('table_with_default_collation')); $row = $adapter->fetchRow(sprintf("SHOW TABLE STATUS WHERE Name = '%s'", 'table_with_default_collation')); - $this->assertEquals('utf8mb4_unicode_ci', $row['Collation']); + $this->assertEquals('utf8mb4_0900_ai_ci', $row['Collation']); } public function testCreateTableWithLatin1Collate() @@ -498,13 +498,13 @@ public function testAddTimestampsFeatureFlag() $this->assertCount(3, $columns); $this->assertSame('id', $columns[0]->getName()); - $this->assertEquals('created_at', $columns[1]->getName()); + $this->assertEquals('created', $columns[1]->getName()); $this->assertEquals('datetime', $columns[1]->getType()); $this->assertEquals('', $columns[1]->getUpdate()); $this->assertFalse($columns[1]->isNull()); $this->assertEquals('CURRENT_TIMESTAMP', $columns[1]->getDefault()); - $this->assertEquals('updated_at', $columns[2]->getName()); + $this->assertEquals('updated', $columns[2]->getName()); $this->assertEquals('datetime', $columns[2]->getType()); $this->assertEquals('CURRENT_TIMESTAMP', $columns[2]->getUpdate()); $this->assertTrue($columns[2]->isNull()); @@ -2153,7 +2153,7 @@ public function testDumpCreateTable() ->save(); $expectedOutput = <<<'OUTPUT' -CREATE TABLE `table1` (`id` INT(11) unsigned NOT NULL AUTO_INCREMENT, `column1` VARCHAR(255) NOT NULL, `column2` INT(11) NULL, `column3` VARCHAR(255) NOT NULL DEFAULT 'test', PRIMARY KEY (`id`)) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE TABLE `table1` (`id` INT(11) unsigned NOT NULL AUTO_INCREMENT, `column1` VARCHAR(255) NOT NULL, `column2` INT(11) NULL, `column3` VARCHAR(255) NOT NULL DEFAULT 'test', PRIMARY KEY (`id`)) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; OUTPUT; $actualOutput = join("\n", $this->out->messages()); $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create table query to the output'); @@ -2258,7 +2258,7 @@ public function testDumpCreateTableAndThenInsert() ])->save(); $expectedOutput = <<<'OUTPUT' -CREATE TABLE `table1` (`column1` VARCHAR(255) NOT NULL, `column2` INT(11) NULL, PRIMARY KEY (`column1`)) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE TABLE `table1` (`column1` VARCHAR(255) NOT NULL, `column2` INT(11) NULL, PRIMARY KEY (`column1`)) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; INSERT INTO `table1` (`column1`, `column2`) VALUES ('id1', 1); OUTPUT; $actualOutput = join("\n", $this->out->messages()); diff --git a/tests/TestCase/Db/Adapter/SqliteAdapterTest.php b/tests/TestCase/Db/Adapter/SqliteAdapterTest.php index a3f95ec7..f1212768 100644 --- a/tests/TestCase/Db/Adapter/SqliteAdapterTest.php +++ b/tests/TestCase/Db/Adapter/SqliteAdapterTest.php @@ -1875,7 +1875,7 @@ public function testNullWithoutDefaultValue() public function testDumpCreateTable() { - $this->adapter->setOptions(['dryrun' => true]); + $this->adapter->setOptions($this->adapter->getOptions() + ['dryrun' => true]); $table = new Table('table1', [], $this->adapter); $table->addColumn('column1', 'string', ['null' => false]) @@ -1902,7 +1902,7 @@ public function testDumpInsert() ->addColumn('int_col', 'integer') ->save(); - $this->adapter->setOptions(['dryrun' => true]); + $this->adapter->setOptions($this->adapter->getOptions() + ['dryrun' => true]); $this->adapter->insert($table->getTable(), [ 'string_col' => 'test data', ]); @@ -1942,7 +1942,7 @@ public function testDumpBulkinsert() ->addColumn('int_col', 'integer') ->save(); - $this->adapter->setOptions(['dryrun' => true]); + $this->adapter->setOptions($this->adapter->getOptions() + ['dryrun' => true]); $this->adapter->bulkinsert($table->getTable(), [ [ 'string_col' => 'test_data1', @@ -1968,7 +1968,7 @@ public function testDumpBulkinsert() public function testDumpCreateTableAndThenInsert() { - $this->adapter->setOptions(['dryrun' => true]); + $this->adapter->setOptions($this->adapter->getOptions() + ['dryrun' => true]); $table = new Table('table1', ['id' => false, 'primary_key' => ['column1']], $this->adapter); $table->addColumn('column1', 'string', ['null' => false]) @@ -2086,8 +2086,8 @@ public function testAlterTableColumnAdd() ['name' => 'string_col', 'type' => 'string', 'default' => '', 'null' => true], ['name' => 'string_col_2', 'type' => 'string', 'default' => null, 'null' => true], ['name' => 'string_col_3', 'type' => 'string', 'default' => null, 'null' => false], - ['name' => 'created_at', 'type' => 'timestamp', 'default' => 'CURRENT_TIMESTAMP', 'null' => false], - ['name' => 'updated_at', 'type' => 'timestamp', 'default' => null, 'null' => true], + ['name' => 'created', 'type' => 'timestamp', 'default' => 'CURRENT_TIMESTAMP', 'null' => false], + ['name' => 'updated', 'type' => 'timestamp', 'default' => null, 'null' => true], ]; $this->assertEquals(count($expected), count($columns)); diff --git a/tests/test_app/config/BaseMigrations/20230628181900_base_migration_tables.php b/tests/test_app/config/BaseMigrations/20230628181900_base_migration_tables.php new file mode 100644 index 00000000..c57cc0ff --- /dev/null +++ b/tests/test_app/config/BaseMigrations/20230628181900_base_migration_tables.php @@ -0,0 +1,28 @@ +table('base_stores', ['collation' => 'utf8_bin']); + $table + ->addColumn('name', 'string') + ->addTimestamps() + ->addPrimaryKey('id') + ->create(); + $io = $this->getIo(); + + $res = $this->query('SELECT 121 as val'); + $io->out('query=' . $res->fetchColumn(0)); + $io->out('fetchRow=' . $this->fetchRow('SELECT 122 as val')['val']); + $io->out('hasTable=' . $this->hasTable('base_stores')); + + // Run for coverage + $this->getSelectBuilder(); + $this->getInsertBuilder(); + $this->getDeleteBuilder(); + $this->getUpdateBuilder(); + } +}