diff --git a/docs/en/writing-migrations.rst b/docs/en/writing-migrations.rst index ab9b0227..6633afa2 100644 --- a/docs/en/writing-migrations.rst +++ b/docs/en/writing-migrations.rst @@ -1249,44 +1249,62 @@ table object. } } -By default Migrations instructs the database adapter to create a normal index. We +By default Migrations instructs the database adapter to create a simple index. We can pass an additional parameter ``unique`` to the ``addIndex()`` method to specify a unique index. We can also explicitly specify a name for the index using the ``name`` parameter, the index columns sort order can also be specified using -the ``order`` parameter. The order parameter takes an array of column names and sort order key/value pairs. +the ``order`` parameter. The order parameter takes an array of column names and sort order key/value pairs:: -.. code-block:: php - - table('users'); - $table->addColumn('email', 'string') - ->addColumn('username','string') - ->addIndex(['email', 'username'], [ - 'unique' => true, - 'name' => 'idx_users_email', - 'order' => ['email' => 'DESC', 'username' => 'ASC']] - ) - ->save(); - } + $table = $this->table('users'); + $table->addColumn('email', 'string') + ->addColumn('username','string') + ->addIndex(['email', 'username'], [ + 'unique' => true, + 'name' => 'idx_users_email', + 'order' => ['email' => 'DESC', 'username' => 'ASC']] + ) + ->save(); + } + } - /** - * Migrate Down. - */ - public function down() - { +As of 4.6.0, you can use ``BaseMigration::index()`` to get a fluent builder to +define indexes:: - } + table('users'); + $table->addColumn('email', 'string') + ->addColumn('username','string') + ->addIndex( + $this->index(['email', 'username']) + ->setType('unique') + ->setName('idx_users_email') + ->setOrder(['email' => 'DESC', 'username' => 'ASC']) + ) + ->save(); } + } + The MySQL adapter also supports ``fulltext`` indexes. If you are using a version before 5.6 you must ensure the table uses the ``MyISAM`` engine. @@ -1308,103 +1326,145 @@ ensure the table uses the ``MyISAM`` engine. } } -In addition, MySQL adapter also supports setting the index length defined by limit option. +MySQL adapter supports setting the index length defined by limit option. When you are using a multi-column index, you are able to define each column index length. -The single column index can define its index length with or without defining column name in limit option. - -.. code-block:: php +The single column index can define its index length with or without defining column name in limit option:: - table('users'); - $table->addColumn('email', 'string') - ->addColumn('username','string') - ->addColumn('user_guid', 'string', ['limit' => 36]) - ->addIndex(['email','username'], ['limit' => ['email' => 5, 'username' => 2]]) - ->addIndex('user_guid', ['limit' => 6]) - ->create(); - } + $table = $this->table('users'); + $table->addColumn('email', 'string') + ->addColumn('username','string') + ->addColumn('user_guid', 'string', ['limit' => 36]) + ->addIndex(['email','username'], ['limit' => ['email' => 5, 'username' => 2]]) + ->addIndex('user_guid', ['limit' => 6]) + ->create(); } + } -The SQL Server and PostgreSQL adapters also supports ``include`` (non-key) columns on indexes. +The SQL Server and PostgreSQL adapters support ``include`` (non-key) columns on indexes:: -.. code-block:: php + table('users'); + $table->addColumn('email', 'string') + ->addColumn('firstname','string') + ->addColumn('lastname','string') + ->addIndex(['email'], ['include' => ['firstname', 'lastname']]) + ->create(); + } + } - class MyNewMigration extends BaseMigration +PostgreSQL, SQLServer, and SQLite support partial indexes by defining where +clauses for the index:: + + table('users'); - $table->addColumn('email', 'string') - ->addColumn('firstname','string') - ->addColumn('lastname','string') - ->addIndex(['email'], ['include' => ['firstname', 'lastname']]) - ->create(); - } + $table = $this->table('users'); + $table->addColumn('email', 'string') + ->addColumn('is_verified','boolean') + ->addIndex( + $this->index('email') + ->setName('user_email_verified_idx') + ->setType('unique') + ->setWhere('is_verified = true') + ) + ->create(); } + } -In addition PostgreSQL adapters also supports Generalized Inverted Index ``gin`` indexes. +PostgreSQL can create indexes concurrently which avoids taking disruptive locks +during index creation:: -.. code-block:: php + table('users'); + $table->addColumn('email', 'string') + ->addIndex( + $this->index('email') + ->setName('user_email_unique_idx') + ->setType('unique') + ->setConcurrently(true) + ) + ->create(); + } + } - class MyNewMigration extends BaseMigration +PostgreSQL adapters also supports Generalized Inverted Index ``gin`` indexes:: + + table('users'); - $table->addColumn('address', 'string') - ->addIndex('address', ['type' => 'gin']) - ->create(); - } + $table = $this->table('users'); + $table->addColumn('address', 'string') + ->addIndex('address', ['type' => 'gin']) + ->create(); } + } Removing indexes is as easy as calling the ``removeIndex()`` method. You must -call this method for each index. - -.. code-block:: php +call this method for each index:: - table('users'); - $table->removeIndex(['email']) - ->save(); + $table = $this->table('users'); + $table->removeIndex(['email']) + ->save(); - // alternatively, you can delete an index by its name, ie: - $table->removeIndexByName('idx_users_email') - ->save(); - } + // alternatively, you can delete an index by its name, ie: + $table->removeIndexByName('idx_users_email') + ->save(); + } - /** - * Migrate Down. - */ - public function down() - { + /** + * Migrate Down. + */ + public function down() + { - } } + } + +.. versionadded:: 4.6.0 + ``Index::setWhere()``, and ``Index::setConcurrently()`` were added. Working With Foreign Keys diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 850c9680..2a71ce6c 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,12 @@ + + + + + getAdapter())]]> + + diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index 3b5663b6..a5e020c4 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -98,7 +98,6 @@ class MysqlAdapter extends PdoAdapter */ public function connect(): void { - $this->getConnection()->getDriver()->connect(); $this->setConnection($this->getConnection()); } diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index 88876ba4..7f188b47 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -1288,23 +1288,28 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin }, $columnNames); $include = $index->getInclude(); - $includedColumns = $include ? sprintf('INCLUDE ("%s")', implode('","', $include)) : ''; + $includedColumns = $include ? sprintf(' INCLUDE ("%s")', implode('","', $include)) : ''; - // TODO always concurrently - $createIndexSentence = 'CREATE %s INDEX %s ON %s '; + $createIndexSentence = 'CREATE %sINDEX%s %s ON %s '; if ($index->getType() === self::GIN_INDEX_TYPE) { $createIndexSentence .= ' USING ' . $index->getType() . '(%s) %s;'; } else { - $createIndexSentence .= '(%s) %s;'; + $createIndexSentence .= '(%s)%s%s;'; + } + $where = (string)$index->getWhere(); + if ($where) { + $where = ' WHERE ' . $where; } return sprintf( $createIndexSentence, - ($index->getType() === Index::UNIQUE ? 'UNIQUE' : ''), + $index->getType() === Index::UNIQUE ? 'UNIQUE ' : '', + $index->getConcurrently() ? ' CONCURRENTLY' : '', $this->quoteColumnName((string)$indexName), $this->quoteTableName($tableName), implode(',', $columnNames), - $includedColumns + $includedColumns, + $where, ); } diff --git a/src/Db/Adapter/SqliteAdapter.php b/src/Db/Adapter/SqliteAdapter.php index 09fa5c24..90db97a1 100644 --- a/src/Db/Adapter/SqliteAdapter.php +++ b/src/Db/Adapter/SqliteAdapter.php @@ -1378,11 +1378,16 @@ protected function getAddIndexInstructions(Table $table, Index $index): AlterIns $indexColumnArray[] = sprintf('`%s` ASC', $column); } $indexColumns = implode(',', $indexColumnArray); + $where = (string)$index->getWhere(); + if ($where) { + $where = ' WHERE ' . $where; + } $sql = sprintf( - 'CREATE %s ON %s (%s)', + 'CREATE %s ON %s (%s)%s', $this->getIndexSqlDefinition($table, $index), $this->quoteTableName($table->getName()), - $indexColumns + $indexColumns, + $where ); return new AlterInstructions([], [$sql]); diff --git a/src/Db/Adapter/SqlserverAdapter.php b/src/Db/Adapter/SqlserverAdapter.php index 26e0b82f..67e1893e 100644 --- a/src/Db/Adapter/SqlserverAdapter.php +++ b/src/Db/Adapter/SqlserverAdapter.php @@ -1201,15 +1201,20 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin }, $columnNames); $include = $index->getInclude(); - $includedColumns = $include ? sprintf('INCLUDE ([%s])', implode('],[', $include)) : ''; + $includedColumns = $include ? sprintf(' INCLUDE ([%s])', implode('],[', $include)) : ''; + $where = (string)$index->getWhere(); + if ($where) { + $where = ' WHERE ' . $where; + } return sprintf( - 'CREATE %s INDEX %s ON %s (%s) %s;', + 'CREATE %s INDEX %s ON %s (%s)%s%s;', ($index->getType() === Index::UNIQUE ? 'UNIQUE' : ''), $indexName, $this->quoteTableName($tableName), implode(',', $columnNames), - $includedColumns + $includedColumns, + $where ); } diff --git a/src/Db/Table.php b/src/Db/Table.php index 150af177..ce036f6b 100644 --- a/src/Db/Table.php +++ b/src/Db/Table.php @@ -35,6 +35,13 @@ use function Cake\Core\deprecationWarning; /** + * Migration Table + * + * Table instances allow you to define schema changes + * to be made on a table. You can manipulate columns, + * indexes and foreign keys. You can also manipulate table + * data with row operations. + * * This object is based loosely on: https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/Table.html. */ class Table diff --git a/src/Db/Table/Index.php b/src/Db/Table/Index.php index d7032896..89cac034 100644 --- a/src/Db/Table/Index.php +++ b/src/Db/Table/Index.php @@ -17,11 +17,6 @@ * * @see \Migrations\BaseMigration::index() * @see \Migrations\Db\Table::addIndex() - * - * TODO expand functionality of Index: - * - Add ifnotexists - * - Add nullsfirst/nullslast - * - Add where for partial indexes */ class Index { @@ -70,6 +65,16 @@ class Index */ protected ?array $includedColumns = null; + /** + * @var bool + */ + protected bool $concurrent = false; + + /** + * @var string|null The where clause for partial indexes. + */ + protected ?string $where = null; + /** * Sets the index columns. * @@ -216,6 +221,54 @@ public function getInclude(): ?array return $this->includedColumns; } + /** + * Set the concurrent mode for an index + * + * In postgres, concurrent indexes don't take locks, but cannot be run within transactions. + * + * @param bool $value The concurrent mode for an index. + * @return $this + */ + public function setConcurrently(bool $value) + { + $this->concurrent = $value; + + return $this; + } + + /** + * Get the concurrent value for an index. + * + * @return bool + */ + public function getConcurrently(): bool + { + return $this->concurrent; + } + + /** + * Set the where clause for partial indexes. + * + * @param ?string $where The where clause for partial indexes. + * @return $this + */ + public function setWhere(?string $where) + { + $this->where = $where; + + return $this; + } + + /** + * Get the where clause for partial indexes. + * + * @return ?string + */ + public function getWhere(): ?string + { + return $this->where; + } + /** * Utility method that maps an array of index options to this objects methods. * @@ -226,7 +279,7 @@ public function getInclude(): ?array public function setOptions(array $options) { // Valid Options - $validOptions = ['type', 'unique', 'name', 'limit', 'order', 'include']; + $validOptions = ['concurrently', 'type', 'unique', 'name', 'limit', 'order', 'include', 'where']; foreach ($options as $option => $value) { if (!in_array($option, $validOptions, true)) { throw new RuntimeException(sprintf('"%s" is not a valid index option.', $option)); diff --git a/src/Table.php b/src/Table.php index 5cc3feca..87be2d08 100644 --- a/src/Table.php +++ b/src/Table.php @@ -21,9 +21,13 @@ use Phinx\Util\Literal; /** - * TODO figure out how to update this for built-in backend. + * Migration Table + * + * This class enhances the phinx provided Table class + * with additional CakePHP related logic. * * @method \Migrations\CakeAdapter getAdapter() + * @deprecated 4.6.0 Use \Migrations\Db\Table instead with the builtin backend. */ class Table extends BaseTable { diff --git a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php index 0538591e..91586ec5 100644 --- a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php +++ b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php @@ -16,6 +16,7 @@ use Migrations\Db\Literal; use Migrations\Db\Table; use Migrations\Db\Table\Column; +use Migrations\Db\Table\Index; use PDO; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Depends; @@ -405,6 +406,68 @@ public function testCreateTableWithNamedIndexes() $this->assertTrue($this->adapter->hasIndexByName('table1', 'myemailindex')); } + public function testCreateTableWithIndexNullOrdering(): void + { + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $index = new Index(); + $index->setColumns('email') + ->setOrder(['email' => 'ASC NULLS FIRST']); + + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex($index) + ->save(); + $queries = $this->out->messages(); + $indexQuery = $queries[3]; + $this->assertStringContainsString('CREATE INDEX "table1_email"', $indexQuery); + $this->assertStringContainsString('("email" ASC NULLS FIRST)', $indexQuery); + } + + public function testCreateTableWithIndexConcurrently(): void + { + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $index = new Index(); + $index->setColumns('email') + ->setType(Index::UNIQUE) + ->setConcurrently(true); + + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addIndex($index) + ->save(); + $queries = $this->out->messages(); + $indexQuery = $queries[3]; + $this->assertStringContainsString('CREATE UNIQUE INDEX CONCURRENTLY "table1_email"', $indexQuery); + } + + public function testCreateTableIndexWithWhere(): void + { + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $index = new Index(); + $index->setColumns('email') + ->setType(Index::UNIQUE) + ->setWhere('is_verified = true'); + + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('is_verified', 'boolean') + ->addIndex($index) + ->save(); + $queries = $this->out->messages(); + $indexQuery = $queries[3]; + $this->assertStringContainsString('CREATE UNIQUE INDEX "table1_email"', $indexQuery); + $this->assertStringContainsString('("email") WHERE is_verified = true', $indexQuery); + } + public function testAddPrimaryKey() { $table = new Table('table1', ['id' => false], $this->adapter); diff --git a/tests/TestCase/Db/Adapter/SqliteAdapterTest.php b/tests/TestCase/Db/Adapter/SqliteAdapterTest.php index f1212768..d6f04e68 100644 --- a/tests/TestCase/Db/Adapter/SqliteAdapterTest.php +++ b/tests/TestCase/Db/Adapter/SqliteAdapterTest.php @@ -18,6 +18,7 @@ use Migrations\Db\Table; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; +use Migrations\Db\Table\Index; use PDOException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -358,6 +359,28 @@ public function testCreateTableWithoutAutoIncrementingPrimaryKeyAndWithForeignKe ); } + public function testCreateTableIndexWithWhere(): void + { + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $index = new Index(); + $index->setColumns('email') + ->setType(Index::UNIQUE) + ->setWhere('is_verified = true'); + + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('is_verified', 'boolean') + ->addIndex($index) + ->save(); + $queries = $this->out->messages(); + $indexQuery = $queries[2]; + $this->assertStringContainsString('CREATE UNIQUE INDEX `table1_email_index`', $indexQuery); + $this->assertStringContainsString('(`email` ASC) WHERE is_verified = true', $indexQuery); + } + public function testAddPrimaryKey() { $table = new Table('table1', ['id' => false], $this->adapter); diff --git a/tests/TestCase/Db/Adapter/SqlserverAdapterTest.php b/tests/TestCase/Db/Adapter/SqlserverAdapterTest.php index 5b9bfa8f..0198132d 100644 --- a/tests/TestCase/Db/Adapter/SqlserverAdapterTest.php +++ b/tests/TestCase/Db/Adapter/SqlserverAdapterTest.php @@ -15,6 +15,7 @@ use Migrations\Db\Table; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; +use Migrations\Db\Table\Index; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\TestCase; @@ -305,6 +306,29 @@ public function testCreateTableWithNamedIndexes() $this->assertTrue($this->adapter->hasIndexByName('table1', 'myemailindex')); } + public function testCreateTableIndexWithWhere(): void + { + $options = $this->adapter->getOptions(); + $options['dryrun'] = true; + $this->adapter->setOptions($options); + + $index = new Index(); + $index->setColumns('email') + ->setName('active_email_index') + ->setType(Index::UNIQUE) + ->setWhere('is_verified = true'); + + $table = new Table('table1', [], $this->adapter); + $table->addColumn('email', 'string') + ->addColumn('is_verified', 'boolean') + ->addIndex($index) + ->save(); + $queries = $this->out->messages(); + $indexQuery = $queries[1]; + $this->assertStringContainsString('CREATE UNIQUE INDEX active_email_index', $indexQuery); + $this->assertStringContainsString('([email]) WHERE is_verified = true', $indexQuery); + } + public function testAddPrimaryKey() { $table = new Table('table1', ['id' => false], $this->adapter);