diff --git a/docs/en/writing-migrations.rst b/docs/en/writing-migrations.rst index e69de29b..ae4815ab 100644 --- a/docs/en/writing-migrations.rst +++ b/docs/en/writing-migrations.rst @@ -0,0 +1,2545 @@ +Writing Migrations +################## + +Migrations are a declarative API that helps you transform your database. Each migration +is represented by a PHP class in a unique file. It is preferred that you write +your migrations using the Migrations API, but raw SQL is also supported. + +Creating a New Migration +======================== + +Let's start by creating a new migration with ``bake``: + +.. code-block:: bash + + $ bin/cake bake migration + +This will create a new migration in the format +``YYYYMMDDHHMMSS_my_new_migration.php``, where the first 14 characters are +replaced with the current timestamp down to the second. + +If you have specified multiple migration paths, you will be asked to select +which path to create the new migration in. + +Bake will automatically creates a skeleton migration file with a single method:: + + table('user_logins'); + $table->addColumn('user_id', 'integer') + ->addColumn('created', 'datetime') + ->create(); + } + } + +When executing this migration, Migrations will create the ``user_logins`` table on +the way up and automatically figure out how to drop the table on the way down. +Please be aware that when a ``change`` method exists, Migrations will +ignore the ``up`` and ``down`` methods. If you need to use these methods it is +recommended to create a separate migration file. + +.. note:: + + When creating or updating tables inside a ``change()`` method you must use + the Table ``create()`` and ``update()`` methods. Migrations cannot automatically + determine whether a ``save()`` call is creating a new table or modifying an + existing one. + +The following actions are reversible when done through the Table API in +Migrations, and will be automatically reversed: + +- Creating a table +- Renaming a table +- Adding a column +- Renaming a column +- Adding an index +- Adding a foreign key +- Adding a check constraint + +If a command cannot be reversed then Migrations will throw an +``IrreversibleMigrationException`` when it's migrating down. If you wish to +use a command that cannot be reversed in the change function, you can use an +if statement with ``$this->isMigratingUp()`` to only run things in the +up or down direction. For example:: + + table('user_logins'); + $table->addColumn('user_id', 'integer') + ->addColumn('created', 'datetime') + ->create(); + if ($this->isMigratingUp()) { + $table->insert([['user_id' => 1, 'created' => '2020-01-19 03:14:07']]) + ->save(); + } + } + } + + +The Up Method +============= + +The up method is automatically run by Migrations when you are migrating up and it +detects the given migration hasn't been executed previously. You should use the +up method to transform the database with your intended changes. + +The Down Method +=============== + +The down method is automatically run by Migrations when you are migrating down and +it detects the given migration has been executed in the past. You should use +the down method to reverse/undo the transformations described in the up method. + +The Init Method +=============== + +The ``init()`` method is run by Migrations before the migration methods if it exists. +This can be used for setting common class properties that are then used within +the migration methods. + +The Should Execute Method +========================= + +The ``shouldExecute()`` method is run by Migrations before executing the migration. +This can be used to prevent the migration from being executed at this time. It always +returns true by default. You can override it in your custom ``BaseMigration`` +implementation. + +Working With Tables +=================== + +The Table object enables you to easily manipulate database tables using PHP +code. You can retrieve an instance of the Table object by calling the +``table()`` method from within your database migration:: + + table('tableName'); + } + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +You can then manipulate this table using the methods provided by the Table +object. + +.. _adding-columns: + +Adding Columns +============== + +Column types are specified as strings and can be one of: + +- binary +- boolean +- char +- date +- datetime +- decimal +- float +- double +- smallinteger +- integer +- biginteger +- string +- text +- time +- timestamp +- uuid +- binaryuuid +- nativeuuid + +In addition, the MySQL adapter supports ``enum``, ``set``, ``blob``, +``tinyblob``, ``mediumblob``, ``longblob``, ``bit`` and ``json`` column types +(``json`` in MySQL 5.7 and above). When providing a limit value and using +``binary``, ``varbinary`` or ``blob`` and its subtypes, the retained column type +will be based on required length (see `Limit Option and MySQL`_ for details). + +With most adapters, the ``uuid`` and ``nativeuuid`` column types are aliases, +however with the MySQL adapter + MariaDB, the ``nativeuuid`` type maps to +a native uuid column instead of ``CHAR(36)`` like ``uuid`` does. + +In addition, the Postgres adapter supports ``interval``, ``json``, ``jsonb``, +``uuid``, ``cidr``, ``inet`` and ``macaddr`` column types (PostgreSQL 9.3 and +above). + +Valid Column Options +-------------------- + +The following are valid column options: + +For any column type: + +======= =========== +Option Description +======= =========== +limit set maximum length for strings, also hints column types in adapters (see note below) +length alias for ``limit`` +default set default value or action +null allow ``NULL`` values, defaults to ``true`` (setting ``identity`` will override default to ``false``) +after specify the column that a new column should be placed after, or use ``\Migrations\Db\Adapter\MysqlAdapter::FIRST`` to place the column at the start of the table *(only applies to MySQL)* +comment set a text comment on the column +======= =========== + +For ``decimal`` and ``float`` columns: + +========= =========== +Option Description +========= =========== +precision total number of digits (e.g., 10 in ``DECIMAL(10,2)``) +scale number of digits after the decimal point (e.g., 2 in ``DECIMAL(10,2)``) +signed enable or disable the ``unsigned`` option *(only applies to MySQL)* +========= =========== + +.. note:: + + **Precision and Scale Terminology** + + Migrations follows the SQL standard where ``precision`` represents the total number of digits, + and ``scale`` represents digits after the decimal point. For example, to create ``DECIMAL(10,2)`` + (10 total digits with 2 decimal places): + + .. code-block:: php + + $table->addColumn('price', 'decimal', [ + 'precision' => 10, // Total digits + 'scale' => 2, // Decimal places + ]); + + This differs from CakePHP's TableSchema which uses ``length`` for total digits and + ``precision`` for decimal places. The migration adapter handles this conversion automatically. + +For ``enum`` and ``set`` columns: + +========= =========== +Option Description +========= =========== +values Can be a comma separated list or an array of values +========= =========== + +For ``smallinteger``, ``integer`` and ``biginteger`` columns: + +======== =========== +Option Description +======== =========== +identity enable or disable automatic incrementing (if enabled, will set ``null: false`` if ``null`` option is not set) +signed enable or disable the ``unsigned`` option *(only applies to MySQL)* +======== + +For Postgres, when using ``identity``, it will utilize the ``serial`` type +appropriate for the integer size, so that ``smallinteger`` will give you +``smallserial``, ``integer`` gives ``serial``, and ``biginteger`` gives +``bigserial``. + +For ``date`` columns: + +======== =========== +Option Description +======== =========== +default set default value (use with ``CURRENT_DATE``) +======== =========== + +For ``time`` columns: + +======== =========== +Option Description +======== =========== +default set default value (use with ``CURRENT_TIME``) +timezone enable or disable the ``with time zone`` option *(only applies to Postgres)* +======== =========== + +For ``datetime`` columns: + +======== =========== +Option Description +======== =========== +default set default value (use with ``CURRENT_TIMESTAMP``) +timezone enable or disable the ``with time zone`` option *(only applies to Postgres)* +======== =========== + +For ``timestamp`` columns: + +======== =========== +Option Description +======== =========== +default set default value (use with ``CURRENT_TIMESTAMP``) +update set an action to be triggered when the row is updated (use with ``CURRENT_TIMESTAMP``) *(only applies to MySQL)* +timezone enable or disable the ``with time zone`` option for ``time`` and ``timestamp`` columns *(only applies to Postgres)* +======== =========== + +You can add ``created`` and ``updated`` timestamps to a table using the +``addTimestamps()`` method. This method accepts three arguments, where the first +two allow setting alternative names for the columns while the third argument +allows you to enable the ``timezone`` option for the columns. The defaults for +these arguments are ``created``, ``updated``, and ``false`` respectively. For +the first and second argument, if you provide ``null``, then the default name +will be used, and if you provide ``false``, then that column will not be +created. Please note that attempting to set both to ``false`` will throw +a ``\RuntimeException``. Additionally, you can use the +``addTimestampsWithTimezone()`` method, which is an alias to ``addTimestamps()`` +that will always set the third argument to ``true`` (see examples below). The +``created`` column will have a default set to ``CURRENT_TIMESTAMP``. For MySQL +only, ``updated`` column will have update set to +``CURRENT_TIMESTAMP``:: + + table('users')->addTimestamps()->create(); + // Use defaults (with timezones) + $table = $this->table('users')->addTimestampsWithTimezone()->create(); + + // Override the 'created' column name with 'recorded_at'. + $table = $this->table('books')->addTimestamps('recorded_at')->create(); + + // Override the 'updated' column name with 'amended_at', preserving timezones. + // The two lines below do the same, the second one is simply cleaner. + $table = $this->table('books')->addTimestamps(null, 'amended_at', true)->create(); + $table = $this->table('users')->addTimestampsWithTimezone(null, 'amended_at')->create(); + + // Only add the created column to the table + $table = $this->table('books')->addTimestamps(null, false); + // Only add the updated column to the table + $table = $this->table('users')->addTimestamps(false); + // Note, setting both false will throw a \RuntimeError + } + } + +For ``boolean`` columns: + +======== =========== +Option Description +======== =========== +signed enable or disable the ``unsigned`` option *(only applies to MySQL)* +======== =========== + +For ``string`` and ``text`` columns: + +========= =========== +Option Description +========= =========== +collation set collation that differs from table defaults *(only applies to MySQL)* +encoding set character set that differs from table defaults *(only applies to MySQL)* +========= =========== + +Limit Option and MySQL +---------------------- + +When using the MySQL adapter, there are a couple things to consider when working with limits: + +- When using a ``string`` primary key or index on MySQL 5.7 or below, or the + MyISAM storage engine, and the default charset of ``utf8mb4_unicode_ci``, you + must specify a limit less than or equal to 191, or use a different charset. +- Additional hinting of database column type can be made for ``integer``, + ``text``, ``blob``, ``tinyblob``, ``mediumblob``, ``longblob`` columns. Using + ``limit`` with one the following options will modify the column type + accordingly: + +============ ============== +Limit Column Type +============ ============== +BLOB_TINY TINYBLOB +BLOB_REGULAR BLOB +BLOB_MEDIUM MEDIUMBLOB +BLOB_LONG LONGBLOB +TEXT_TINY TINYTEXT +TEXT_REGULAR TEXT +TEXT_MEDIUM MEDIUMTEXT +TEXT_LONG LONGTEXT +INT_TINY TINYINT +INT_SMALL SMALLINT +INT_MEDIUM MEDIUMINT +INT_REGULAR INT +INT_BIG BIGINT +============ ============== + +For ``binary`` or ``varbinary`` types, if limit is set greater than allowed 255 +bytes, the type will be changed to the best matching blob type given the +length:: + + table('cart_items'); + $table->addColumn('user_id', 'integer') + ->addColumn('product_id', 'integer', ['limit' => MysqlAdapter::INT_BIG]) + ->addColumn('subtype_id', 'integer', ['limit' => MysqlAdapter::INT_SMALL]) + ->addColumn('quantity', 'integer', ['limit' => MysqlAdapter::INT_TINY]) + ->create(); + +Default values with expressions +------------------------------- + +If you need to set a default to an expression, you can use a ``Literal`` to have +the column's default value used without any quoting or escaping. This is helpful +when you want to use a function as a default value:: + + use Migrations\BaseMigration; + use Migrations\Db\Literal; + + class AddSomeColumns extends BaseMigration + { + public function change(): void + { + $this->table('users') + ->addColumn('uniqid', 'uuid', [ + 'default' => Literal::from('uuid_generate_v4()') + ]) + ->create(); + } + } + +.. _creating-a-table:: + +Creating a Table +---------------- + +Creating a table is really easy using the Table object. Let's create a table to +store a collection of users:: + + table('users'); + $users->addColumn('username', 'string', ['limit' => 20]) + ->addColumn('password', 'string', ['limit' => 40]) + ->addColumn('password_salt', 'string', ['limit' => 40]) + ->addColumn('email', 'string', ['limit' => 100]) + ->addColumn('first_name', 'string', ['limit' => 30]) + ->addColumn('last_name', 'string', ['limit' => 30]) + ->addColumn('created', 'datetime') + ->addColumn('updated', 'datetime', ['null' => true]) + ->addIndex(['username', 'email'], ['unique' => true]) + ->create(); + } + } + +Columns are added using the ``addColumn()`` method. We create a unique index +for both the username and email columns using the ``addIndex()`` method. +Finally calling ``create()`` commits the changes to the database. + +.. note:: + + Migrations automatically creates an auto-incrementing primary key column called ``id`` for every + table. + +The ``id`` option sets the name of the automatically created identity field, +while the ``primary_key`` option selects the field or fields used for primary +key. ``id`` will always override the ``primary_key`` option unless it's set to +false. If you don't need a primary key set ``id`` to false without specifying +a ``primary_key``, and no primary key will be created. + +To specify an alternate primary key, you can specify the ``primary_key`` option +when accessing the Table object. Let's disable the automatic ``id`` column and +create a primary key using two columns instead:: + + table('followers', ['id' => false, 'primary_key' => ['user_id', 'follower_id']]); + $table->addColumn('user_id', 'integer') + ->addColumn('follower_id', 'integer') + ->addColumn('created', 'datetime') + ->create(); + } + } + +Setting a single ``primary_key`` doesn't enable the ``AUTO_INCREMENT`` option. +To simply change the name of the primary key, we need to override the default ``id`` field name:: + + table('followers', ['id' => 'user_id']); + $table->addColumn('follower_id', 'integer') + ->addColumn('created', 'timestamp', ['default' => 'CURRENT_TIMESTAMP']) + ->create(); + } + } + +In addition, the MySQL adapter supports following options: + +========== ================ =========== +Option Platform Description +========== ================ =========== +comment MySQL, Postgres set a text comment on the table +collation MySQL, SqlServer set the table collation *(defaults to database collation)* +row_format MySQL set the table row format +engine MySQL define table engine *(defaults to ``InnoDB``)* +signed MySQL whether the primary key is ``signed`` *(defaults to ``true``)* +limit MySQL set the maximum length for the primary key +========== ================ =========== + +By default, the primary key is ``signed``. +To set it to be unsigned, pass the ``signed`` option with a ``false`` +value, or enable the ``Migrations.unsigned_primary_keys`` and +``Migrations.unsigned_ints`` feature flags (see :ref:`feature-flags`). +Both flags should be used together so that foreign key columns match +the primary keys they reference:: + + table('followers', ['signed' => false]); + $table->addColumn('follower_id', 'integer') + ->addColumn('created', 'timestamp', ['default' => 'CURRENT_TIMESTAMP']) + ->create(); + } + } + +If you need to create a table with a different collation than the database, +use:: + + table('categories', [ + 'collation' => 'latin1_german1_ci' + ]) + ->addColumn('title', 'string') + ->create(); + } + } + +Note however this can only be done on table creation : there is currently no way +of adding a column to an existing table with a different collation than the +table or the database. Only ``MySQL`` and ``SqlServer`` supports this +configuration key for the time being. + +To view available column types and options, see :ref:`adding-columns` for details. + +MySQL ALTER TABLE Options +------------------------- + +.. versionadded:: 5.0.0 + ``ALGORITHM`` and ``LOCK`` options were added in 5.0.0. + +When modifying tables in MySQL, you can control how the ALTER TABLE operation is +performed using the ``algorithm`` and ``lock`` options. This is useful for performing +zero-downtime schema changes on large tables in production environments. + +.. code-block:: php + + table('large_table'); + $table->addIndex(['status'], [ + 'name' => 'idx_status', + ]); + $table->update([ + 'algorithm' => 'INPLACE', + 'lock' => 'NONE', + ]); + } + } + +Available ``algorithm`` values: + +============ =========== +Algorithm Description +============ =========== +DEFAULT Let MySQL choose the algorithm (default behavior) +INPLACE Modify the table in place without copying data (when possible) +COPY Create a copy of the table with the changes (legacy method) +INSTANT Only modify metadata, no table rebuild (MySQL 8.0+, limited operations) +============ =========== + +Available ``lock`` values: + +========= =========== +Lock Description +========= =========== +DEFAULT Use minimal locking for the algorithm (default behavior) +NONE Allow concurrent reads and writes during the operation +SHARED Allow concurrent reads but block writes +EXCLUSIVE Block all reads and writes during the operation +========= =========== + +.. note:: + + Not all operations support all algorithm/lock combinations. MySQL will raise + an error if the requested combination is not possible for the operation. + The ``INSTANT`` algorithm is only available in MySQL 8.0+ and only for specific + operations like adding columns at the end of a table. + +.. warning:: + + Using ``ALGORITHM=INPLACE, LOCK=NONE`` does not guarantee zero-downtime for + all operations. Some operations may still require a table copy or exclusive lock. + Always test schema changes on a staging environment first. + +Table Partitioning +------------------ + +Migrations supports table partitioning for MySQL and PostgreSQL. Partitioning helps +manage large tables by splitting them into smaller, more manageable pieces. + +.. note:: + + Partition columns must be included in the primary key for MySQL. SQLite does + not support partitioning. MySQL's ``RANGE`` and ``LIST`` types only work with + integer columns - use ``RANGE COLUMNS`` and ``LIST COLUMNS`` for DATE/STRING columns. + +RANGE Partitioning +~~~~~~~~~~~~~~~~~~ + +RANGE partitioning is useful when you want to partition by numeric ranges. For MySQL, +use ``TYPE_RANGE`` with integer columns or expressions, and ``TYPE_RANGE_COLUMNS`` for +DATE/DATETIME/STRING columns:: + + table('orders', [ + 'id' => false, + 'primary_key' => ['id', 'order_date'], + ]); + $table->addColumn('id', 'integer', ['identity' => true]) + ->addColumn('order_date', 'date') + ->addColumn('amount', 'decimal', ['precision' => 10, 'scale' => 2]) + ->partitionBy(Partition::TYPE_RANGE_COLUMNS, 'order_date') + ->addPartition('p2022', '2023-01-01') + ->addPartition('p2023', '2024-01-01') + ->addPartition('p2024', '2025-01-01') + ->addPartition('pmax', 'MAXVALUE') + ->create(); + } + } + +LIST Partitioning +~~~~~~~~~~~~~~~~~ + +LIST partitioning is useful when you want to partition by discrete values. For MySQL, +use ``TYPE_LIST`` with integer columns and ``TYPE_LIST_COLUMNS`` for STRING columns:: + + table('customers', [ + 'id' => false, + 'primary_key' => ['id', 'region'], + ]); + $table->addColumn('id', 'integer', ['identity' => true]) + ->addColumn('region', 'string', ['limit' => 20]) + ->addColumn('name', 'string') + ->partitionBy(Partition::TYPE_LIST_COLUMNS, 'region') + ->addPartition('p_americas', ['US', 'CA', 'MX', 'BR']) + ->addPartition('p_europe', ['UK', 'DE', 'FR', 'IT']) + ->addPartition('p_asia', ['JP', 'CN', 'IN', 'KR']) + ->create(); + } + } + +HASH Partitioning +~~~~~~~~~~~~~~~~~ + +HASH partitioning distributes data evenly across a specified number of partitions:: + + table('sessions'); + $table->addColumn('user_id', 'integer') + ->addColumn('data', 'text') + ->partitionBy(Partition::TYPE_HASH, 'user_id', ['count' => 8]) + ->create(); + } + } + +KEY Partitioning (MySQL only) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +KEY partitioning is similar to HASH but uses MySQL's internal hashing function:: + + table('cache', [ + 'id' => false, + 'primary_key' => ['cache_key'], + ]); + $table->addColumn('cache_key', 'string', ['limit' => 255]) + ->addColumn('value', 'binary') + ->partitionBy(Partition::TYPE_KEY, 'cache_key', ['count' => 16]) + ->create(); + } + } + +Partitioning with Expressions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can partition by expressions using the ``Literal`` class:: + + table('events', [ + 'id' => false, + 'primary_key' => ['id', 'created_at'], + ]); + $table->addColumn('id', 'integer', ['identity' => true]) + ->addColumn('created_at', 'datetime') + ->partitionBy(Partition::TYPE_RANGE, Literal::from('YEAR(created_at)')) + ->addPartition('p2022', 2023) + ->addPartition('p2023', 2024) + ->addPartition('pmax', 'MAXVALUE') + ->create(); + } + } + +Modifying Partitions on Existing Tables +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can add or drop partitions on existing partitioned tables:: + + table('orders') + ->addPartitionToExisting('p2025', '2026-01-01') + ->update(); + } + + public function down(): void + { + // Drop the partition + $this->table('orders') + ->dropPartition('p2025') + ->update(); + } + } + +Saving Changes +-------------- + +When working with the Table object, Migrations stores certain operations in a +pending changes cache. Once you have made the changes you want to the table, +you must save them. To perform this operation, Migrations provides three methods, +``create()``, ``update()``, and ``save()``. ``create()`` will first create +the table and then run the pending changes. ``update()`` will just run the +pending changes, and should be used when the table already exists. ``save()`` +is a helper function that checks first if the table exists and if it does not +will run ``create()``, else it will run ``update()``. + +As stated above, when using the ``change()`` migration method, you should always +use ``create()`` or ``update()``, and never ``save()`` as otherwise migrating +and rolling back may result in different states, due to ``save()`` calling +``create()`` when running migrate and then ``update()`` on rollback. When +using the ``up()``/``down()`` methods, it is safe to use either ``save()`` or +the more explicit methods. + +When in doubt with working with tables, it is always recommended to call +the appropriate function and commit any pending changes to the database. + + +Renaming a Column +----------------- + +To rename a column, access an instance of the Table object then call the +``renameColumn()`` method:: + + table('users'); + $table->renameColumn('bio', 'biography') + ->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + $table = $this->table('users'); + $table->renameColumn('biography', 'bio') + ->save(); + } + } + +Adding a Column After Another Column +------------------------------------ + +When adding a column with the MySQL adapter, you can dictate its position using +the ``after`` option, where its value is the name of the column to position it +after:: + + table('users'); + $table->addColumn('city', 'string', ['after' => 'email']) + ->update(); + } + } + +This would create the new column ``city`` and position it after the ``email`` +column. The ``\Migrations\Db\Adapter\MysqlAdapter::FIRST`` constant can be used +to specify that the new column should be created as the first column in that +table. + +Dropping a Column +----------------- + +To drop a column, use the ``removeColumn()`` method:: + + table('users'); + $table->removeColumn('short_name') + ->save(); + } + } + + +Specifying a Column Limit +------------------------- + +You can limit the maximum length of a column by using the ``limit`` option:: + + table('tags'); + $table->addColumn('short_name', 'string', ['limit' => 30]) + ->update(); + } + } + +Changing Column Attributes +-------------------------- + +There are two methods for modifying existing columns: + +Updating Columns (Recommended) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To modify specific column attributes while preserving others, use the ``updateColumn()`` method. +This method automatically preserves unspecified attributes like defaults, nullability, limits, etc.:: + + table('users'); + // Make email nullable, preserving all other attributes + $users->updateColumn('email', null, ['null' => true]) + ->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + $users = $this->table('users'); + $users->updateColumn('email', null, ['null' => false]) + ->save(); + } + } + +You can pass ``null`` as the column type to preserve the existing type, or specify a new type:: + + // Preserve type and other attributes, only change nullability + $table->updateColumn('email', null, ['null' => true]); + + // Change type to biginteger, preserve default and other attributes + $table->updateColumn('user_id', 'biginteger'); + + // Change default value, preserve everything else + $table->updateColumn('status', null, ['default' => 'active']); + +The following attributes are automatically preserved by ``updateColumn()``: + +- Default values +- NULL/NOT NULL constraint +- Column limit/length +- Decimal scale/precision +- Comments +- Signed/unsigned (for numeric types) +- Collation and encoding +- Enum/set values + +Changing Columns (Traditional) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To completely replace a column definition, use the ``changeColumn()`` method. +This method requires you to specify all desired column attributes. +See :ref:`valid-column-types` and `Valid Column Options`_ for allowed values:: + + table('users'); + // Must specify all attributes + $users->changeColumn('email', 'string', [ + 'limit' => 255, + 'null' => true, + 'default' => null, + ]) + ->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +You can enable attribute preservation with ``changeColumn()`` by passing +``'preserveUnspecified' => true`` in the options:: + + $table->changeColumn('email', 'string', [ + 'null' => true, + 'preserveUnspecified' => true, + ]); + +.. note:: + + For most use cases, ``updateColumn()`` is recommended as it is safer and requires + less code. Use ``changeColumn()`` when you need to completely redefine a column + or when working with legacy code that expects the traditional behavior. + +Working With Indexes +-------------------- + +To add an index to a table you can simply call the ``addIndex()`` method on the +table object:: + + table('users'); + $table->addColumn('city', 'string') + ->addIndex(['city']) + ->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +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:: + + table('users'); + $table->addColumn('email', 'string') + ->addColumn('username','string') + ->addIndex(['email', 'username'], [ + 'unique' => true, + 'name' => 'idx_users_email', + 'order' => ['email' => 'DESC', 'username' => 'ASC']] + ) + ->save(); + } + } + +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:: + + table('users', ['engine' => 'MyISAM']); + $table->addColumn('email', 'string') + ->addIndex('email', ['type' => 'fulltext']) + ->create(); + } + } + +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:: + + 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 support ``include`` (non-key) columns on indexes:: + + table('users'); + $table->addColumn('email', 'string') + ->addColumn('firstname','string') + ->addColumn('lastname','string') + ->addIndex(['email'], ['include' => ['firstname', 'lastname']]) + ->create(); + } + } + +PostgreSQL, SQLServer, and SQLite support partial indexes by defining where +clauses for the index:: + + 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(); + } + } + +PostgreSQL can create indexes concurrently which avoids taking disruptive locks +during index creation:: + + table('users'); + $table->addColumn('email', 'string') + ->addIndex( + $this->index('email') + ->setName('user_email_unique_idx') + ->setType('unique') + ->setConcurrently(true) + ) + ->create(); + } + } + +PostgreSQL Index Access Methods +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +PostgreSQL supports several index access methods beyond the default B-tree. +Use the ``type`` option to specify the access method. + +**GIN (Generalized Inverted Index)** + +GIN indexes are useful for full-text search, arrays, and JSONB columns:: + + table('articles'); + $table->addColumn('tags', 'jsonb') + ->addIndex('tags', ['type' => 'gin']) + ->create(); + } + } + +**GiST (Generalized Search Tree)** + +GiST indexes support geometric data, range types, and full-text search. +For trigram similarity searches (requires the ``pg_trgm`` extension), use +the ``opclass`` option:: + + table('products'); + $table->addColumn('name', 'string') + ->addIndex('name', [ + 'type' => 'gist', + 'opclass' => ['name' => 'gist_trgm_ops'], + ]) + ->create(); + } + } + +**BRIN (Block Range Index)** + +BRIN indexes are highly efficient for large, naturally-ordered tables like +time-series data. They are much smaller than B-tree indexes but only work well +when data is physically ordered by the indexed column:: + + table('sensor_readings'); + $table->addColumn('recorded_at', 'timestamp') + ->addColumn('value', 'decimal') + ->addIndex('recorded_at', ['type' => 'brin']) + ->create(); + } + } + +**SP-GiST (Space-Partitioned GiST)** + +SP-GiST indexes work well for data with natural clustering, like IP addresses +or phone numbers:: + + table('access_logs'); + $table->addColumn('client_ip', 'inet') + ->addIndex('client_ip', ['type' => 'spgist']) + ->create(); + } + } + +**Hash** + +Hash indexes handle simple equality comparisons. They are rarely needed since +B-tree handles equality efficiently too:: + + table('sessions'); + $table->addColumn('session_id', 'string', ['limit' => 64]) + ->addIndex('session_id', ['type' => 'hash']) + ->create(); + } + } + +Removing indexes is as easy as calling the ``removeIndex()`` method. You must +call this method for each index:: + + table('users'); + $table->removeIndex(['email']) + ->save(); + + // alternatively, you can delete an index by its name, ie: + $table->removeIndexByName('idx_users_email') + ->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +.. versionadded:: 4.6.0 + ``Index::setWhere()``, and ``Index::setConcurrently()`` were added. + + +Working With Foreign Keys +------------------------- + +Migrations has support for creating foreign key constraints on your database tables. +Let's add a foreign key to an example table:: + + table('tags'); + $table->addColumn('tag_name', 'string') + ->save(); + + $refTable = $this->table('tag_relationships'); + $refTable->addColumn('tag_id', 'integer', ['null' => true]) + ->addForeignKey( + 'tag_id', + 'tags', + 'id', + ['delete'=> 'SET_NULL', 'update'=> 'NO_ACTION'], + ) + ->save(); + + } + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +The 'delete' and 'update' options allow you to define the ``ON UPDATE`` and ``ON +DELETE`` behavior. Possibles values are 'SET_NULL', 'NO_ACTION', 'CASCADE' and +'RESTRICT'. If 'SET_NULL' is used then the column must be created as nullable +with the option ``['null' => true]``. + +Foreign keys can be defined with arrays of columns to build constraints between +tables with composite keys:: + + table('follower_events'); + $table->addColumn('user_id', 'integer') + ->addColumn('follower_id', 'integer') + ->addColumn('event_id', 'integer') + ->addForeignKey( + ['user_id', 'follower_id'], + 'followers', + ['user_id', 'follower_id'], + [ + 'delete'=> 'NO_ACTION', + 'update'=> 'NO_ACTION', + 'constraint' => 'user_follower_id', + ] + ) + ->save(); + } + } + +The options parameter of ``addForeignKey()`` supports the following options: + +========== =========== +Option Description +========== =========== +update set an action to be triggered when the row is updated +delete set an action to be triggered when the row is deleted +constraint set a name to be used by foreign key constraint +deferrable define deferred constraint application (postgres only) +========== =========== + +Using the ``foreignKey()`` method provides a fluent builder to define a foreign +key:: + + table('articles'); + $table->addForeignKey( + $this->foreignKey() + ->setColumns('user_id') + ->setReferencedTable('users') + ->setReferencedColumns('user_id') + ->setDelete(ForeignKey::CASCADE) + ->setName('article_user_fk') + ) + ->save(); + } + } + +.. versionadded:: 4.6.0 + The ``foreignKey`` method was added. + +We can also easily check if a foreign key exists:: + + table('tag_relationships'); + $exists = $table->hasForeignKey('tag_id'); + if ($exists) { + // do something + } + } + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +Finally, to delete a foreign key, use the ``dropForeignKey`` method. + +Note that like other methods in the ``Table`` class, ``dropForeignKey`` also +needs ``save()`` to be called at the end in order to be executed. This allows +Migrations to intelligently plan migrations when more than one table is +involved:: + + table('tag_relationships'); + $table->dropForeignKey('tag_id')->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +Working With Check Constraints +------------------------------- + +.. versionadded:: 5.0.0 + Check constraints were added in 5.0.0. + +Check constraints allow you to enforce data validation rules at the database level. +They are particularly useful for ensuring data integrity across your application. + +.. note:: + + Check constraints are supported by MySQL 8.0.16+, PostgreSQL, and SQLite. + SQL Server support is planned for a future release. + +Adding a Check Constraint +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can add a check constraint to a table using the ``addCheckConstraint()`` method:: + + table('products'); + $table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->addCheckConstraint('price > 0', ['name' => 'price_positive']) + ->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + $table = $this->table('products'); + $table->dropCheckConstraint('price_positive') + ->save(); + } + } + +The first argument is the SQL expression that defines the constraint. The expression +should evaluate to a boolean value. The second argument is an options array where +you can specify the constraint ``name``. + +Using the CheckConstraint Object +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For more complex scenarios, you can create a ``CheckConstraint`` object directly:: + + table('users'); + $table->addColumn('age', 'integer') + ->addColumn('status', 'string', ['limit' => 20]) + ->addCheckConstraint( + new CheckConstraint('age_valid', 'age >= 18 AND age <= 120') + ) + ->addCheckConstraint( + new CheckConstraint('status_valid', "status IN ('active', 'inactive', 'pending')") + ) + ->save(); + } + } + +Auto-Generated Constraint Names +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you don't specify a constraint name, one will be automatically generated:: + + table('inventory'); + $table->addColumn('quantity', 'integer') + // Name will be auto-generated + ->addCheckConstraint('quantity >= 0') + ->save(); + } + } + +Complex Check Constraints +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Check constraints can reference multiple columns and use complex SQL expressions:: + + table('date_ranges'); + $table->addColumn('start_date', 'date') + ->addColumn('end_date', 'date') + ->addColumn('discount', 'decimal', ['precision' => 5, 'scale' => 2]) + ->addCheckConstraint('end_date >= start_date', ['name' => 'valid_date_range']) + ->addCheckConstraint('discount BETWEEN 0 AND 100', ['name' => 'valid_discount']) + ->save(); + } + } + +Checking if a Check Constraint Exists +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can verify if a check constraint exists using the ``hasCheckConstraint()`` method:: + + table('products'); + $exists = $table->hasCheckConstraint('price_positive'); + if ($exists) { + // do something + } else { + $table->addCheckConstraint('price > 0', ['name' => 'price_positive']) + ->save(); + } + } + } + +Dropping a Check Constraint +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To remove a check constraint, use the ``dropCheckConstraint()`` method with the +constraint name:: + + table('products'); + $table->dropCheckConstraint('price_positive') + ->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + $table = $this->table('products'); + $table->addCheckConstraint('price > 0', ['name' => 'price_positive']) + ->save(); + } + } + +.. note:: + + Like other table operations, ``dropCheckConstraint()`` requires ``save()`` + to be called to execute the change. + +Database-Specific Behavior +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**MySQL (8.0.16+)** + +Check constraints are fully supported. MySQL stores constraint metadata in the +``INFORMATION_SCHEMA.CHECK_CONSTRAINTS`` table. + +**PostgreSQL** + +Check constraints are fully supported and stored in the ``pg_constraint`` catalog. +PostgreSQL allows the most flexible expressions in check constraints. + +**SQLite** + +Check constraints are supported but with some limitations. SQLite does not support +``ALTER TABLE`` operations for check constraints, so adding or dropping constraints +requires recreating the entire table. This is handled automatically by the adapter. + +**SQL Server** + +Check constraint support for SQL Server is planned for a future release. + +Determining Whether a Table Exists +---------------------------------- + +You can determine whether or not a table exists by using the ``hasTable()`` +method:: + + hasTable('users'); + if ($exists) { + // do something + } + } + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +Dropping a Table +---------------- + +Tables can be dropped quite easily using the ``drop()`` method. It is a +good idea to recreate the table again in the ``down()`` method. + +Note that like other methods in the ``Table`` class, ``drop`` also needs ``save()`` +to be called at the end in order to be executed. This allows Migrations to intelligently +plan migrations when more than one table is involved:: + + table('users')->drop()->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + $users = $this->table('users'); + $users->addColumn('username', 'string', ['limit' => 20]) + ->addColumn('password', 'string', ['limit' => 40]) + ->addColumn('password_salt', 'string', ['limit' => 40]) + ->addColumn('email', 'string', ['limit' => 100]) + ->addColumn('first_name', 'string', ['limit' => 30]) + ->addColumn('last_name', 'string', ['limit' => 30]) + ->addColumn('created', 'datetime') + ->addColumn('updated', 'datetime', ['null' => true]) + ->addIndex(['username', 'email'], ['unique' => true]) + ->save(); + } + } + +Renaming a Table +---------------- + +To rename a table access an instance of the Table object then call the +``rename()`` method:: + + table('users'); + $table + ->rename('legacy_users') + ->update(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + $table = $this->table('legacy_users'); + $table + ->rename('users') + ->update(); + } + } + +Changing the Primary Key +------------------------ + +To change the primary key on an existing table, use the ``changePrimaryKey()`` +method. Pass in a column name or array of columns names to include in the +primary key, or ``null`` to drop the primary key. Note that the mentioned +columns must be added to the table, they will not be added implicitly:: + + table('users'); + $users + ->addColumn('username', 'string', ['limit' => 20, 'null' => false]) + ->addColumn('password', 'string', ['limit' => 40]) + ->save(); + + $users + ->addColumn('new_id', 'integer', ['null' => false]) + ->changePrimaryKey(['new_id', 'username']) + ->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +Creating Custom Primary Keys +---------------------------- + +You can specify a ``autoId`` property in the Migration class and set it to +``false``, which will turn off the automatic ``id`` column creation. You will +need to manually create the column that will be used as a primary key and add +it to the table declaration:: + + table('products'); + $table + ->addColumn('id', 'uuid') + ->addPrimaryKey('id') + ->addColumn('name', 'string') + ->addColumn('description', 'text') + ->create(); + } + } + +The above will create a ``CHAR(36)`` ``id`` column that is also the primary key. + +When specifying a custom primary key on the command line, you must note +it as the primary key in the id field, otherwise you may get an error +regarding duplicate id fields, i.e.: + +.. code-block:: bash + + bin/cake bake migration CreateProducts id:uuid:primary name:string description:text created modified + + +All baked migrations and snapshot will use this new way when necessary. + +.. warning:: + + Dealing with primary key can only be done on table creation operations. + This is due to limitations for some database servers the plugin supports. + +Changing the Table Comment +-------------------------- + +To change the comment on an existing table, use the ``changeComment()`` method. +Pass in a string to set as the new table comment, or ``null`` to drop the existing comment:: + + table('users'); + $users + ->addColumn('username', 'string', ['limit' => 20]) + ->addColumn('password', 'string', ['limit' => 40]) + ->save(); + + $users + ->changeComment('This is the table with users auth information, password should be encrypted') + ->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +Checking Columns +================ + +``BaseMigration`` also provides methods for introspecting the current schema, +allowing you to conditionally make changes to schema, or read data. +Schema is inspected **when the migration is run**. + +Get a column list +----------------- + +To retrieve all table columns, simply create a ``table`` object and call +``getColumns()`` method. This method will return an array of Column classes with +basic info. Example below:: + + table('users')->getColumns(); + ... + } + + /** + * Migrate Down. + */ + public function down(): void + { + ... + } + } + +Get a column by name +-------------------- + +To retrieve one table column, simply create a ``table`` object and call the +``getColumn()`` method. This method will return a Column class with basic info +or NULL when the column doesn't exist. Example below:: + + table('users')->getColumn('email'); + ... + } + + /** + * Migrate Down. + */ + public function down(): void + { + ... + } + } + +Checking whether a column exists +-------------------------------- + +You can check if a table already has a certain column by using the +``hasColumn()`` method:: + + table('user'); + $column = $table->hasColumn('username'); + + if ($column) { + // do something + } + + } + } + + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +Dropping a Table +---------------- + +Tables can be dropped quite easily using the ``drop()`` method. It is a +good idea to recreate the table again in the ``down()`` method. + +Note that like other methods in the ``Table`` class, ``drop`` also needs ``save()`` +to be called at the end in order to be executed. This allows Migrations to intelligently +plan migrations when more than one table is involved:: + + table('users')->drop()->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + $users = $this->table('users'); + $users->addColumn('username', 'string', ['limit' => 20]) + ->addColumn('password', 'string', ['limit' => 40]) + ->addColumn('password_salt', 'string', ['limit' => 40]) + ->addColumn('email', 'string', ['limit' => 100]) + ->addColumn('first_name', 'string', ['limit' => 30]) + ->addColumn('last_name', 'string', ['limit' => 30]) + ->addColumn('created', 'datetime') + ->addColumn('updated', 'datetime', ['null' => true]) + ->addIndex(['username', 'email'], ['unique' => true]) + ->save(); + } + } + +Renaming a Table +---------------- + +To rename a table access an instance of the Table object then call the +``rename()`` method:: + + table('users'); + $table + ->rename('legacy_users') + ->update(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + $table = $this->table('legacy_users'); + $table + ->rename('users') + ->update(); + } + } + +Changing the Primary Key +------------------------ + +To change the primary key on an existing table, use the ``changePrimaryKey()`` +method. Pass in a column name or array of columns names to include in the +primary key, or ``null`` to drop the primary key. Note that the mentioned +columns must be added to the table, they will not be added implicitly:: + + table('users'); + $users + ->addColumn('username', 'string', ['limit' => 20, 'null' => false]) + ->addColumn('password', 'string', ['limit' => 40]) + ->save(); + + $users + ->addColumn('new_id', 'integer', ['null' => false]) + ->changePrimaryKey(['new_id', 'username']) + ->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +Creating Custom Primary Keys +---------------------------- + +You can specify a ``autoId`` property in the Migration class and set it to +``false``, which will turn off the automatic ``id`` column creation. You will +need to manually create the column that will be used as a primary key and add +it to the table declaration:: + + table('products'); + $table + ->addColumn('id', 'uuid') + ->addPrimaryKey('id') + ->addColumn('name', 'string') + ->addColumn('description', 'text') + ->create(); + } + } + +The above will create a ``CHAR(36)`` ``id`` column that is also the primary key. + +When specifying a custom primary key on the command line, you must note +it as the primary key in the id field, otherwise you may get an error +regarding duplicate id fields, i.e.: + +.. code-block:: bash + + bin/cake bake migration CreateProducts id:uuid:primary name:string description:text created modified + + +All baked migrations and snapshot will use this new way when necessary. + +.. warning:: + + Dealing with primary key can only be done on table creation operations. + This is due to limitations for some database servers the plugin supports. + +Changing the Table Comment +-------------------------- + +To change the comment on an existing table, use the ``changeComment()`` method. +Pass in a string to set as the new table comment, or ``null`` to drop the existing comment:: + + table('users'); + $users + ->addColumn('username', 'string', ['limit' => 20]) + ->addColumn('password', 'string', ['limit' => 40]) + ->save(); + + $users + ->changeComment('This is the table with users auth information, password should be encrypted') + ->save(); + } + + /** + * Migrate Down. + */ + public function down(): void + { + + } + } + +Checking Columns +================ + +``BaseMigration`` also provides methods for introspecting the current schema, +allowing you to conditionally make changes to schema, or read data. +Schema is inspected **when the migration is run**. + +Get a column list +----------------- + +To retrieve all table columns, simply create a ``table`` object and call +``getColumns()`` method. This method will return an array of Column classes with +basic info. Example below:: + + table('users')->getColumns(); + ... + } + + /** + * Migrate Down. + */ + public function down(): void + { + ... + } + } + +Get a column by name +-------------------- + +To retrieve one table column, simply create a ``table`` object and call the +``getColumn()`` method. This method will return a Column class with basic info +or NULL when the column doesn't exist. Example below:: + + table('users')->getColumn('email'); + ... + } + + /** + * Migrate Down. + */ + public function down(): void + { + ... + } + } + +Checking whether a column exists +-------------------------------- + +You can check if a table already has a certain column by using the +``hasColumn()`` method:: + + table('user'); + $column = $table->hasColumn('username'); + + if ($column) { + // do something + } + + } + } + + +Changing templates +------------------ + +See :ref:`custom-seed-migration-templates` for how to customize the templates +used to generate migrations. + +Database-Specific Limitations +============================= + +While Migrations aims to provide a database-agnostic API, some features have +database-specific limitations or are not available on all platforms. + +SQL Server +---------- + +The following features are not supported on SQL Server: + +**Check Constraints** + +Check constraints are not currently implemented for SQL Server. Attempting to +use ``addCheckConstraint()`` or ``dropCheckConstraint()`` will throw a +``BadMethodCallException``. + +**Table Comments** + +SQL Server does not support table comments. Attempting to use ``changeComment()`` +will throw a ``BadMethodCallException``. + +**INSERT IGNORE / insertOrSkip()** + +SQL Server does not support the ``INSERT IGNORE`` syntax used by ``insertOrSkip()``. +This method will throw a ``RuntimeException`` on SQL Server. Use ``insertOrUpdate()`` +instead for upsert operations, which uses ``MERGE`` statements on SQL Server. + +SQLite +------ + +**Foreign Key Names** + +SQLite does not support named foreign keys. The foreign key constraint name option +is ignored when creating foreign keys on SQLite. + +**Table Comments** + +SQLite does not support table comments directly. Comments are stored as metadata +but not in the database itself. + +**Check Constraint Modifications** + +SQLite does not support ``ALTER TABLE`` operations for check constraints. Adding or +dropping check constraints requires recreating the entire table, which is handled +automatically by the adapter. + +**Table Partitioning** + +SQLite does not support table partitioning. + +PostgreSQL +---------- + +**KEY Partitioning** + +PostgreSQL does not support MySQL's ``KEY`` partitioning type. Use ``HASH`` +partitioning instead for similar distribution behavior. + +MySQL/MariaDB +------------- + +**insertOrUpdate() Conflict Columns** + +For MySQL, the ``$conflictColumns`` parameter in ``insertOrUpdate()`` is ignored +because MySQL's ``ON DUPLICATE KEY UPDATE`` automatically applies to all unique +constraints. PostgreSQL and SQLite require this parameter to be specified. + +**MariaDB GIS/Geometry** + +Some geometry column features may not work correctly on MariaDB due to differences +in GIS implementation compared to MySQL. diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index 7fe0f744..3ff57a1f 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -58,7 +58,18 @@ class PostgresAdapter extends AbstractAdapter self::TYPE_NATIVE_UUID, ]; - private const GIN_INDEX_TYPE = 'gin'; + /** + * PostgreSQL index access methods that require USING clause. + * + * @var array + */ + private const ACCESS_METHOD_TYPES = [ + Index::GIN, + Index::GIST, + Index::SPGIST, + Index::BRIN, + Index::HASH, + ]; /** * Columns with comments @@ -925,8 +936,16 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin } $order = $index->getOrder() ?? []; - $columnNames = array_map(function (string $columnName) use ($order): string { + $opclass = $index->getOpclass() ?? []; + $columnNames = array_map(function (string $columnName) use ($order, $opclass): string { $ret = '"' . $columnName . '"'; + + // Add operator class if specified (e.g., gist_trgm_ops) + if (isset($opclass[$columnName])) { + $ret .= ' ' . $opclass[$columnName]; + } + + // Add ordering if specified (e.g., ASC NULLS FIRST) if (isset($order[$columnName])) { $ret .= ' ' . $order[$columnName]; } @@ -937,11 +956,11 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin $include = $index->getInclude(); $includedColumns = $include ? sprintf(' INCLUDE ("%s")', implode('","', $include)) : ''; - $createIndexSentence = 'CREATE %sINDEX%s %s ON %s '; - if ($index->getType() === self::GIN_INDEX_TYPE) { - $createIndexSentence .= ' USING ' . $index->getType() . '(%s) %s;'; - } else { - $createIndexSentence .= '(%s)%s%s;'; + // Build USING clause for access method types (gin, gist, spgist, brin, hash) + $indexType = $index->getType(); + $usingClause = ''; + if (in_array($indexType, self::ACCESS_METHOD_TYPES, true)) { + $usingClause = ' USING ' . $indexType; } $where = ''; $whereClause = $index->getWhere(); @@ -950,11 +969,12 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin } return sprintf( - $createIndexSentence, - $index->getType() === Index::UNIQUE ? 'UNIQUE ' : '', + 'CREATE %sINDEX%s %s ON %s%s (%s)%s%s;', + $indexType === Index::UNIQUE ? 'UNIQUE ' : '', $index->getConcurrently() ? ' CONCURRENTLY' : '', $this->quoteColumnName($indexName), $this->quoteTableName($tableName), + $usingClause, implode(',', $columnNames), $includedColumns, $where, diff --git a/src/Db/Table/Index.php b/src/Db/Table/Index.php index af965397..507509af 100644 --- a/src/Db/Table/Index.php +++ b/src/Db/Table/Index.php @@ -36,6 +36,46 @@ class Index extends DatabaseIndex */ public const FULLTEXT = 'fulltext'; + /** + * PostgreSQL index access method: Generalized Inverted Index. + * Useful for full-text search, arrays, and JSONB columns. + * + * @var string + */ + public const GIN = 'gin'; + + /** + * PostgreSQL index access method: Generalized Search Tree. + * Useful for geometric data, range types, and full-text search. + * + * @var string + */ + public const GIST = 'gist'; + + /** + * PostgreSQL index access method: Space-Partitioned GiST. + * Useful for data with natural clustering like IP addresses or phone numbers. + * + * @var string + */ + public const SPGIST = 'spgist'; + + /** + * PostgreSQL index access method: Block Range Index. + * Highly efficient for large, naturally-ordered tables like time-series data. + * + * @var string + */ + public const BRIN = 'brin'; + + /** + * PostgreSQL index access method: Hash index. + * Handles simple equality comparisons. Rarely needed since B-tree handles equality efficiently. + * + * @var string + */ + public const HASH = 'hash'; + /** * Constructor * @@ -49,6 +89,7 @@ class Index extends DatabaseIndex * @param bool $concurrent Whether to create the index concurrently. * @param ?string $algorithm The ALTER TABLE algorithm (MySQL-specific). * @param ?string $lock The ALTER TABLE lock mode (MySQL-specific). + * @param array|null $opclass The operator class for each column (PostgreSQL). */ public function __construct( protected string $name = '', @@ -61,6 +102,7 @@ public function __construct( protected bool $concurrent = false, protected ?string $algorithm = null, protected ?string $lock = null, + protected ?array $opclass = null, ) { } @@ -199,6 +241,34 @@ public function getLock(): ?string return $this->lock; } + /** + * Set the operator class for index columns. + * + * Operator classes specify which operators the index can use. This is primarily + * useful in PostgreSQL for specialized index types like GiST with trigram support. + * + * Example: ['column_name' => 'gist_trgm_ops'] + * + * @param array $opclass Map of column names to operator classes. + * @return $this + */ + public function setOpclass(array $opclass) + { + $this->opclass = $opclass; + + return $this; + } + + /** + * Get the operator class configuration for index columns. + * + * @return array|null + */ + public function getOpclass(): ?array + { + return $this->opclass; + } + /** * Utility method that maps an array of index options to this object's methods. * @@ -209,7 +279,7 @@ public function getLock(): ?string public function setOptions(array $options) { // Valid Options - $validOptions = ['concurrently', 'type', 'unique', 'name', 'limit', 'order', 'include', 'where', 'algorithm', 'lock']; + $validOptions = ['concurrently', 'type', 'unique', 'name', 'limit', 'order', 'include', 'where', 'algorithm', 'lock', 'opclass']; 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/tests/TestCase/Db/Adapter/PostgresAdapterTest.php b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php index 22ce3bf9..8e9fb253 100644 --- a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php +++ b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php @@ -8,6 +8,7 @@ use Cake\Console\TestSuite\StubConsoleOutput; use Cake\Database\Connection; use Cake\Datasource\ConnectionManager; +use Exception; use InvalidArgumentException; use Migrations\Db\Adapter\AdapterInterface; use Migrations\Db\Adapter\PostgresAdapter; @@ -1497,6 +1498,162 @@ public function testDropIndexByNameWithSchema(): void $this->adapter->dropSchema('schema1'); } + public function testAddGistIndex(): void + { + // GiST indexes require specific data types with GiST support. + // We use int4range which has built-in GiST support in PostgreSQL. + $this->adapter->execute('CREATE TABLE table1 (id SERIAL PRIMARY KEY, int_range int4range)'); + + $table = new Table('table1', [], $this->adapter); + $table->addIndex('int_range', ['type' => 'gist']) + ->save(); + + $this->assertTrue($table->hasIndex('int_range')); + + // Verify the index uses the GIST access method + $rows = $this->adapter->fetchAll( + "SELECT am.amname as access_method + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'table1' AND c.relname = 'table1_int_range'", + ); + $this->assertCount(1, $rows); + $this->assertEquals('gist', $rows[0]['access_method']); + } + + public function testAddGinIndex(): void + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('tags', 'jsonb') + ->save(); + + $table->addIndex('tags', ['type' => 'gin']) + ->save(); + + $this->assertTrue($table->hasIndex('tags')); + + // Verify the index uses the GIN access method + $rows = $this->adapter->fetchAll( + "SELECT am.amname as access_method + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'table1' AND c.relname = 'table1_tags'", + ); + $this->assertCount(1, $rows); + $this->assertEquals('gin', $rows[0]['access_method']); + } + + public function testAddBrinIndex(): void + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('created_at', 'timestamp') + ->save(); + + $table->addIndex('created_at', ['type' => 'brin']) + ->save(); + + $this->assertTrue($table->hasIndex('created_at')); + + // Verify the index uses the BRIN access method + $rows = $this->adapter->fetchAll( + "SELECT am.amname as access_method + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'table1' AND c.relname = 'table1_created_at'", + ); + $this->assertCount(1, $rows); + $this->assertEquals('brin', $rows[0]['access_method']); + } + + public function testAddHashIndex(): void + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('session_id', 'string', ['limit' => 64]) + ->save(); + + $table->addIndex('session_id', ['type' => 'hash']) + ->save(); + + $this->assertTrue($table->hasIndex('session_id')); + + // Verify the index uses the HASH access method + $rows = $this->adapter->fetchAll( + "SELECT am.amname as access_method + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'table1' AND c.relname = 'table1_session_id'", + ); + $this->assertCount(1, $rows); + $this->assertEquals('hash', $rows[0]['access_method']); + } + + public function testAddSpgistIndex(): void + { + // SP-GiST indexes on text require the text_ops operator class + $table = new Table('table1', [], $this->adapter); + $table->addColumn('data', 'text') + ->save(); + + $table->addIndex('data', ['type' => 'spgist', 'opclass' => ['data' => 'text_ops']]) + ->save(); + + $this->assertTrue($table->hasIndex('data')); + + // Verify the index uses the SP-GIST access method + $rows = $this->adapter->fetchAll( + "SELECT am.amname as access_method + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'table1' AND c.relname = 'table1_data'", + ); + $this->assertCount(1, $rows); + $this->assertEquals('spgist', $rows[0]['access_method']); + } + + public function testAddIndexWithOpclass(): void + { + // Test opclass with GiST using pg_trgm extension + // Skip if extension is not available + try { + $this->adapter->execute('CREATE EXTENSION IF NOT EXISTS pg_trgm'); + } catch (Exception) { + $this->markTestSkipped('pg_trgm extension is not available'); + } + + $table = new Table('table1', [], $this->adapter); + $table->addColumn('name', 'string') + ->save(); + + $table->addIndex('name', [ + 'type' => 'gist', + 'opclass' => ['name' => 'gist_trgm_ops'], + ])->save(); + + $this->assertTrue($table->hasIndex('name')); + + // Verify the index was created with the correct access method + $rows = $this->adapter->fetchAll( + "SELECT am.amname as access_method + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_am am ON am.oid = c.relam + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.relname = 'table1' AND c.relname = 'table1_name'", + ); + $this->assertCount(1, $rows); + $this->assertEquals('gist', $rows[0]['access_method']); + } + public function testAddForeignKey(): void { $refTable = new Table('ref_table', [], $this->adapter);