diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 320eb7961..3a8002246 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: - 3.x - 4.x - 4.next + - 5.x pull_request: branches: - '*' @@ -20,18 +21,22 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.1', '8.4'] + php-version: ['8.2', '8.5'] db-type: [mariadb, mysql, pgsql, sqlite] prefer-lowest: [''] - cake_version: [''] + legacy-tables: [''] include: - - php-version: '8.1' + - php-version: '8.2' db-type: 'sqlite' prefer-lowest: 'prefer-lowest' - php-version: '8.3' db-type: 'mysql' - php-version: '8.3' db-type: 'pgsql' + # Test unified cake_migrations table (non-legacy mode) + - php-version: '8.3' + db-type: 'mysql' + legacy-tables: 'false' services: postgres: image: postgres @@ -106,13 +111,9 @@ jobs: - name: Composer install run: | if [[ ${{ matrix.php-version }} == '8.2' || ${{ matrix.php-version }} == '8.3' || ${{ matrix.php-version }} == '8.4' ]]; then - composer install --ignore-platform-req=php + composer install elif ${{ matrix.prefer-lowest == 'prefer-lowest' }}; then composer update --prefer-lowest --prefer-stable - elif ${{ matrix.cake_version != '' }}; then - composer require --dev "cakephp/cakephp:${{ matrix.cake_version }}" - composer require --dev --with-all-dependencies "cakephp/bake:dev-3.next as 3.1.0" - composer update else composer update fi @@ -139,6 +140,9 @@ jobs: export DB_URL='postgres://postgres:pg-password@127.0.0.1/cakephp_test' export DB_URL_SNAPSHOT='postgres://postgres:pg-password@127.0.0.1/cakephp_snapshot' fi + if [[ -n '${{ matrix.legacy-tables }}' ]]; then + export LEGACY_TABLES='${{ matrix.legacy-tables }}' + fi if [[ ${{ matrix.php-version }} == '8.1' && ${{ matrix.db-type }} == 'mysql' ]]; then vendor/bin/phpunit --coverage-clover=coverage.xml else @@ -151,11 +155,11 @@ jobs: testsuite-windows: runs-on: windows-2022 - name: Windows - PHP 8.1 & SQL Server + name: Windows - PHP 8.2 & SQL Server env: EXTENSIONS: mbstring, intl, pdo_sqlsrv - PHP_VERSION: '8.1' + PHP_VERSION: '8.2' steps: - uses: actions/checkout@v6 diff --git a/.phive/phars.xml b/.phive/phars.xml index f5aa33004..4d447d1a0 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,4 +1,4 @@ - + diff --git a/README.md b/README.md index 878cca376..7d4bacbf2 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # Migrations plugin for CakePHP [![CI](https://github.com/cakephp/migrations/actions/workflows/ci.yml/badge.svg)](https://github.com/cakephp/migrations/actions/workflows/ci.yml) -[![Coverage Status](https://img.shields.io/codecov/c/github/cakephp/migrations/3.x.svg?style=flat-square)](https://app.codecov.io/github/cakephp/migrations/tree/3.x) +[![Coverage Status](https://img.shields.io/codecov/c/github/cakephp/migrations/5.x.svg?style=flat-square)](https://app.codecov.io/github/cakephp/migrations/tree/5.x) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.txt) [![Total Downloads](https://img.shields.io/packagist/dt/cakephp/migrations.svg?style=flat-square)](https://packagist.org/packages/cakephp/migrations) This is a Database Migrations system for CakePHP. -The plugin consists of a CakePHP CLI wrapper for the [Phinx](https://book.cakephp.org/phinx/0/en/index.html) migrations library. +The plugin provides a complete database migration solution with support for creating, running, and managing migrations. This branch is for use with CakePHP **5.x**. See [version map](https://github.com/cakephp/migrations/wiki#version-map) for details. @@ -33,21 +33,6 @@ If you are using the PendingMigrations middleware, use: bin/cake plugin load Migrations ``` -### Enabling the builtin backend - -In a future release, migrations will be switching to a new backend based on the CakePHP ORM. We're aiming -to be compatible with as many existing migrations as possible, and could use your feedback. Enable the -new backend with: - -```php -// in app/config/app_local.php -$config = [ - // Other configuration - 'Migrations' => ['backend' => 'builtin'], -]; - -``` - ## Documentation -Full documentation of the plugin can be found on the [CakePHP Cookbook](https://book.cakephp.org/migrations/4/). +Full documentation of the plugin can be found on the [CakePHP Cookbook](https://book.cakephp.org/migrations/5/). diff --git a/composer.json b/composer.json index 76b5bd952..0d0597f15 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "cakephp/migrations", - "description": "Database Migration plugin for CakePHP based on Phinx", + "description": "Database Migration plugin for CakePHP", "license": "MIT", "type": "cakephp-plugin", "keywords": [ @@ -22,17 +22,16 @@ "source": "https://github.com/cakephp/migrations" }, "require": { - "php": ">=8.1", - "cakephp/cache": "^5.2.9", - "cakephp/database": "^5.2.9", - "cakephp/orm": "^5.2.9", - "robmorgan/phinx": "^0.16.10" + "php": ">=8.2", + "cakephp/cache": "^5.3.0", + "cakephp/database": "^5.3.0", + "cakephp/orm": "^5.3.0" }, "require-dev": { "cakephp/bake": "^3.3", - "cakephp/cakephp": "^5.2.9", + "cakephp/cakephp": "^5.3.0", "cakephp/cakephp-codesniffer": "^5.0", - "phpunit/phpunit": "^10.5.5 || ^11.1.3 || ^12.2.4" + "phpunit/phpunit": "^11.5.3 || ^12.1.3" }, "suggest": { "cakephp/bake": "If you want to generate migrations.", @@ -53,7 +52,9 @@ "Migrator\\": "tests/test_app/Plugin/Migrator/src/", "SimpleSnapshot\\": "tests/test_app/Plugin/SimpleSnapshot/src/", "TestApp\\": "tests/test_app/App/", - "TestBlog\\": "tests/test_app/Plugin/TestBlog/src/" + "TestBlog\\": "tests/test_app/Plugin/TestBlog/src/", + "Blog\\": "tests/test_app/Plugin/Blog/src/", + "Migrator\\": "tests/test_app/Plugin/Migrator/src/" } }, "config": { @@ -67,8 +68,8 @@ "@stan", "@test" ], - "cs-check": "phpcs -p", - "cs-fix": "phpcbf -p", + "cs-check": "phpcs --parallel=16 -p", + "cs-fix": "phpcbf --parallel=16 -p", "phpstan": "tools/phpstan analyse", "stan": "@phpstan", "stan-baseline": "tools/phpstan --generate-baseline", diff --git a/config/app.example.php b/config/app.example.php index ee200e9c7..3fc952b06 100644 --- a/config/app.example.php +++ b/config/app.example.php @@ -6,8 +6,8 @@ return [ 'Migrations' => [ - 'backend' => 'builtin', - 'unsigned_primary_keys' => null, - 'column_null_default' => null, + 'unsigned_primary_keys' => null, // Default false + 'unsigned_ints' => null, // Default false, make sure this is aligned with the above config + 'column_null_default' => null, // Default false ], ]; diff --git a/docs/en/index.rst b/docs/en/index.rst index f61032556..7936f48ad 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -3,7 +3,7 @@ Migrations Migrations is a plugin that lets you track changes to your database schema over time as PHP code that accompanies your application. This lets you ensure each -environment your application runs in can has the appropriate schema by applying +environment your application runs in has the appropriate schema by applying migrations. Instead of writing schema modifications in SQL, this plugin allows you to @@ -43,6 +43,12 @@ your application in your **config/app.php** file as explained in the `Database Configuration section `__. +Upgrading from 4.x +================== + +If you are upgrading from Migrations 4.x, please see the :doc:`upgrading` guide +for breaking changes and migration steps. + Overview ======== @@ -218,7 +224,7 @@ also edit the migration after generation to add or customize the columns Columns on the command line follow the following pattern:: - fieldName:fieldType?[length]:indexType:indexName + fieldName:fieldType?[length]:default[value]:indexType:indexName For instance, the following are all valid ways of specifying an email field: @@ -238,6 +244,16 @@ Columns with a question mark after the fieldType will make the column nullable. The ``length`` part is optional and should always be written between bracket. +The ``default[value]`` part is optional and sets the default value for the column. +Supported value types include: + +* Booleans: ``true`` or ``false`` - e.g., ``active:boolean:default[true]`` +* Integers: ``0``, ``123``, ``-456`` - e.g., ``count:integer:default[0]`` +* Floats: ``1.5``, ``-2.75`` - e.g., ``rate:decimal:default[1.5]`` +* Strings: ``'hello'`` or ``"world"`` (quoted) - e.g., ``status:string:default['pending']`` +* Null: ``null`` or ``NULL`` - e.g., ``description:text?:default[null]`` +* SQL expressions: ``CURRENT_TIMESTAMP`` - e.g., ``created_at:datetime:default[CURRENT_TIMESTAMP]`` + Fields named ``created`` and ``modified``, as well as any field with a ``_at`` suffix, will automatically be set to the type ``datetime``. @@ -318,6 +334,39 @@ will generate:: } } +Adding a column with a default value +------------------------------------- + +You can specify default values for columns using the ``default[value]`` syntax: + +.. code-block:: bash + + bin/cake bake migration AddActiveToUsers active:boolean:default[true] + +will generate:: + + table('users'); + $table->addColumn('active', 'boolean', [ + 'default' => true, + 'null' => false, + ]); + $table->update(); + } + } + +You can combine default values with other options like nullable and indexes: + +.. code-block:: bash + + bin/cake bake migration AddStatusToOrders status:string:default['pending']:unique + Altering a column ----------------- @@ -419,8 +468,8 @@ for use in unit tests), you can use the ``--generate-only`` flag: bin/cake bake migration_snapshot Initial --generate-only -This will create the migration file but will not add an entry to the phinxlog -table, allowing you to move the file to a different location without causing +This will create the migration file but will not add an entry to the migrations +tracking table, allowing you to move the file to a different location without causing "MISSING" status issues. The same logic will be applied implicitly if you wish to bake a snapshot for a @@ -554,15 +603,15 @@ Cleaning up missing migrations ------------------------------- Sometimes migration files may be deleted from the filesystem but still exist -in the phinxlog table. These migrations will be marked as **MISSING** in the -status output. You can remove these entries from the phinxlog table using the +in the migrations tracking table. These migrations will be marked as **MISSING** in the +status output. You can remove these entries from the tracking table using the ``--cleanup`` option: .. code-block:: bash bin/cake migrations status --cleanup -This will remove all migration entries from the phinxlog table that no longer +This will remove all migration entries from the tracking table that no longer have corresponding migration files in the filesystem. Marking a migration as migrated @@ -795,9 +844,10 @@ pass them to the method:: Feature Flags ============= -Migrations offers a few feature flags to compatibility with phinx. These features are disabled by default but can be enabled if required: +Migrations offers a few feature flags for compatibility. These features are disabled by default but can be enabled if required: * ``unsigned_primary_keys``: Should Migrations create primary keys as unsigned integers? (default: ``false``) +* ``unsigned_ints``: Should Migrations create all integer columns as unsigned? (default: ``false``) * ``column_null_default``: Should Migrations create columns as null by default? (default: ``false``) * ``add_timestamps_use_datetime``: Should Migrations use ``DATETIME`` type columns for the columns added by ``addTimestamps()``. @@ -806,9 +856,18 @@ Set them via Configure to enable (e.g. in ``config/app.php``):: 'Migrations' => [ 'unsigned_primary_keys' => true, + 'unsigned_ints' => true, 'column_null_default' => true, ], +.. note:: + + The ``unsigned_primary_keys`` and ``unsigned_ints`` options only affect MySQL databases. + When generating migrations with ``bake migration_snapshot`` or ``bake migration_diff``, + the ``signed`` attribute will only be included in the output for unsigned columns + (as ``'signed' => false``). Signed is the default for integer columns in MySQL, so + ``'signed' => true`` is never output. + Skipping the ``schema.lock`` file generation ============================================ diff --git a/docs/en/seeding.rst b/docs/en/seeding.rst index 01bbf5b70..80d46f698 100644 --- a/docs/en/seeding.rst +++ b/docs/en/seeding.rst @@ -18,11 +18,12 @@ Migrations includes a command to easily generate a new seed class: $ bin/cake bake seed MyNewSeed -It is based on a skeleton template: +By default, it generates a traditional seed class with a named class: .. code-block:: php call('Articles');`` or ``$this->call('ArticlesSeed');`` + +Using the short name is recommended for cleaner, more concise code. + +Anonymous Seed Classes +---------------------- + +Migrations also supports generating anonymous seed classes, which use PHP's +anonymous class feature instead of named classes. This style is useful for: + +- Avoiding namespace declarations +- Better PHPCS compatibility (no class name to filename matching required) +- Simpler file structure without named class constraints + +To generate an anonymous seed class, use the ``--style anonymous`` option: + +.. code-block:: bash + + $ bin/cake bake seed MyNewSeed --style anonymous + +This generates a seed file using an anonymous class: + +.. code-block:: php + + [ + 'style' => 'anonymous', // or 'traditional' + ], + +Seed Options +------------ + .. code-block:: bash # You specify the name of the table the seed files will alter by using the ``--table`` option bin/cake bake seed Articles --table my_articles_table @@ -92,8 +165,10 @@ migrations should be on one of the following paths: - ``ROOT/templates/plugin/Migrations/bake/`` - ``ROOT/templates/bake/`` -For example the seed template is ``Seed/seed.twig`` and its full path would be -**ROOT/templates/plugin/Migrations/bake/Seed/seed.twig** +For example, the seed templates are: + +- Traditional: ``Seed/seed.twig`` at **ROOT/templates/plugin/Migrations/bake/Seed/seed.twig** +- Anonymous: ``Seed/seed-anonymous.twig`` at **ROOT/templates/plugin/Migrations/bake/Seed/seed-anonymous.twig** The BaseSeed Class ================== @@ -106,14 +181,120 @@ The Run Method ============== The run method is automatically invoked by Migrations when you execute the -``cake migration seed`` command. You should use this method to insert your test +``cake seeds run`` command. You should use this method to insert your test data. +Seed Execution Tracking +======================== + +Seeds track their execution state in the ``cake_seeds`` database table. By default, +a seed will only run once. If you attempt to run a seed that has already been +executed, it will be skipped with an "already executed" message. + +To re-run a seed that has already been executed, use the ``--force`` flag: + +.. code-block:: bash + + bin/cake seeds run Users --force + +You can check which seeds have been executed using the status command: + +.. code-block:: bash + + bin/cake seeds status + +To reset all seeds' execution state (allowing them to run again without ``--force``): + +.. code-block:: bash + + bin/cake seeds reset + .. note:: - Unlike with migrations, seeds do not keep track of which seed classes have - been run. This means database seeds can be run repeatedly. Keep this in - mind when developing them. + When re-running seeds with ``--force``, be careful to ensure your seeds are + idempotent (safe to run multiple times) or they may create duplicate data. + +Customizing the Seed Tracking Table +------------------------------------ + +By default, seed execution is tracked in a table named ``cake_seeds``. You can +customize this table name by configuring it in your ``config/app.php`` or +``config/app_local.php``: + +.. code-block:: php + + 'Migrations' => [ + 'seed_table' => 'my_custom_seeds_table', + ], + +This is useful if you need to avoid table name conflicts or want to follow +a specific naming convention in your database. + +Idempotent Seeds +================ + +Some seeds are designed to be run multiple times safely (idempotent), such as seeds +that update configuration or reference data. For these seeds, you can override the +``isIdempotent()`` method to skip tracking entirely: + +.. code-block:: php + + execute(" + INSERT INTO settings (setting_key, setting_value) + VALUES ('app_version', '2.0.0') + ON DUPLICATE KEY UPDATE setting_value = '2.0.0' + "); + + // Or check before inserting + $exists = $this->fetchRow( + "SELECT COUNT(*) as count FROM settings WHERE setting_key = 'maintenance_mode'" + ); + + if ($exists['count'] == 0) { + $this->table('settings')->insert([ + 'setting_key' => 'maintenance_mode', + 'setting_value' => 'false', + ])->save(); + } + } + } + +When ``isIdempotent()`` returns ``true``: + +- The seed will **not** be tracked in the ``cake_seeds`` table +- The seed will run **every time** you execute ``seeds run`` +- You must ensure the seed's ``run()`` method handles duplicate executions safely + +This is useful for: + +- Configuration seeds that should always reflect current values +- Reference data that may need periodic updates +- Seeds that use ``INSERT ... ON DUPLICATE KEY UPDATE`` or similar patterns +- Development/testing seeds that need to run repeatedly + +.. warning:: + + Only mark a seed as idempotent if you've verified it's safe to run multiple times. + Otherwise, you may create duplicate data or other unexpected behavior. The Init Method =============== @@ -148,22 +329,51 @@ current seed: public function getDependencies(): array { return [ - 'UserSeed', - 'ShopItemSeed' + 'User', // Short name without 'Seed' suffix + 'ShopItem', // Short name without 'Seed' suffix ]; } public function run() : void { - // Seed the shopping cart after the `UserSeed` and + // Seed the shopping cart after the `UserSeed` and // `ShopItemSeed` have been run. } } +You can also use the full seed name including the ``Seed`` suffix: + +.. code-block:: php + + return [ + 'UserSeed', + 'ShopItemSeed', + ]; + +Both forms are supported and work identically. + +Automatic Dependency Execution +------------------------------- + +When you run a seed that has dependencies, the system will automatically check if +those dependencies have been executed. If any dependencies haven't run yet, they +will be executed automatically before the current seed runs. This ensures proper +execution order and prevents foreign key constraint violations. + +For example, if you run: + +.. code-block:: bash + + bin/cake seeds run ShoppingCartSeed + +And ``ShoppingCartSeed`` depends on ``UserSeed`` and ``ShopItemSeed``, the system +will automatically execute those dependencies first if they haven't been run yet. + .. note:: - Dependencies are only considered when executing all seed classes (default behavior). - They won't be considered when running specific seed classes. + Dependencies that have already been executed (according to the ``cake_seeds`` + table) will be skipped, unless you use the ``--force`` flag which will + re-execute all seeds including dependencies. Calling a Seed from another Seed @@ -184,14 +394,24 @@ method to define your own sequence of seeds execution: { public function run(): void { - $this->call('AnotherSeed'); - $this->call('YetAnotherSeed'); + $this->call('Another'); // Short name without 'Seed' suffix + $this->call('YetAnother'); // Short name without 'Seed' suffix // You can use the plugin dot syntax to call seeds from a plugin - $this->call('PluginName.FromPluginSeed'); + $this->call('PluginName.FromPlugin'); } } +You can also use the full seed name including the ``Seed`` suffix: + +.. code-block:: php + + $this->call('AnotherSeed'); + $this->call('YetAnotherSeed'); + $this->call('PluginName.FromPluginSeed'); + +Both forms are supported and work identically. + Inserting Data ============== @@ -230,6 +450,89 @@ within your seed class and then use the ``insert()`` method to insert data: You must call the ``saveData()`` method to commit your data to the table. Migrations will buffer data until you do so. +Insert Modes +============ + +In addition to the standard ``insert()`` method, Migrations provides specialized +insert methods for handling conflicts with existing data. + +Insert or Skip +-------------- + +The ``insertOrSkip()`` method inserts rows but silently skips any that would +violate a unique constraint: + +.. code-block:: php + + 'USD', 'name' => 'US Dollar'], + ['code' => 'EUR', 'name' => 'Euro'], + ]; + + $this->table('currencies') + ->insertOrSkip($data) + ->saveData(); + } + } + +Insert or Update (Upsert) +------------------------- + +The ``insertOrUpdate()`` method performs an "upsert" operation - inserting new +rows and updating existing rows that conflict on unique columns: + +.. code-block:: php + + 'USD', 'rate' => 1.0000], + ['code' => 'EUR', 'rate' => 0.9234], + ]; + + $this->table('exchange_rates') + ->insertOrUpdate($data, ['rate'], ['code']) + ->saveData(); + } + } + +The method takes three arguments: + +- ``$data``: The rows to insert (same format as ``insert()``) +- ``$updateColumns``: Which columns to update when a conflict occurs +- ``$conflictColumns``: Which columns define uniqueness (must have a unique index) + +.. warning:: + + Database-specific behavior differences: + + **MySQL**: Uses ``ON DUPLICATE KEY UPDATE``. The ``$conflictColumns`` parameter + is ignored because MySQL automatically applies the update to *all* unique + constraint violations on the table. Passing ``$conflictColumns`` will trigger + a warning. If your table has multiple unique constraints, be aware that a + conflict on *any* of them will trigger the update. + + **PostgreSQL/SQLite**: Uses ``ON CONFLICT (...) DO UPDATE SET``. The + ``$conflictColumns`` parameter is required and specifies exactly which unique + constraint should trigger the update. A ``RuntimeException`` will be thrown + if this parameter is empty. + + **SQL Server**: Not currently supported. Use separate insert/update logic. + Truncating Tables ================= @@ -275,30 +578,37 @@ SQL `TRUNCATE` command: Executing Seed Classes ====================== -This is the easy part. To seed your database, simply use the ``migrations seed`` command: +This is the easy part. To seed your database, simply use the ``seeds run`` command: .. code-block:: bash - $ bin/cake migrations seed + $ bin/cake seeds run By default, Migrations will execute all available seed classes. If you would like to -run a specific class, simply pass in the name of it using the ``--seed`` parameter: +run a specific seed, simply pass in the seed name as an argument. +You can use either the short name (without the ``Seed`` suffix) or the full name: .. code-block:: bash - $ bin/cake migrations seed --seed UserSeed + $ bin/cake seeds run User + # or + $ bin/cake seeds run UserSeed + +Both commands work identically. -You can also run multiple seeds: +You can also run multiple seeds by separating them with commas: .. code-block:: bash - $ bin/cake migrations seed --seed UserSeed --seed PermissionSeed --seed LogSeed + $ bin/cake seeds run User,Permission,Log + # or with full names + $ bin/cake seeds run UserSeed,PermissionSeed,LogSeed You can also use the `-v` parameter for more output verbosity: .. code-block:: bash - $ bin/cake migrations seed -v + $ bin/cake seeds run -v The Migrations seed functionality provides a simple mechanism to easily and repeatably insert test data into your database, this is great for development environment diff --git a/docs/en/upgrading-to-builtin-backend.rst b/docs/en/upgrading-to-builtin-backend.rst index b1837263b..ea2909433 100644 --- a/docs/en/upgrading-to-builtin-backend.rst +++ b/docs/en/upgrading-to-builtin-backend.rst @@ -3,22 +3,81 @@ Upgrading to the builtin backend As of migrations 4.3 there is a new migrations backend that uses CakePHP's database abstractions and ORM. In 4.4, the ``builtin`` backend became the -default backend. Longer term this will allow for phinx to be -removed as a dependency. This greatly reduces the dependency footprint of -migrations. +default backend. As of migrations 5.0, phinx has been removed as a dependency +and only the builtin backend is supported. This greatly reduces the dependency +footprint of migrations. What is the same? ================= Your migrations shouldn't have to change much to adapt to the new backend. -The migrations backend implements all of the phinx interfaces and can run -migrations based on phinx classes. If your migrations don't work in a way that -could be addressed by the changes outlined below, please open an issue, as we'd -like to maintain as much compatibility as we can. +The builtin backend provides similar functionality to what was available with +phinx. If your migrations don't work in a way that could be addressed by the +changes outlined below, please open an issue. What is different? ================== +Command Structure Changes +------------------------- + +As of migrations 5.0, the command structure has changed. The old phinx wrapper +commands have been removed and replaced with new command names: + +**Seeds:** + +.. code-block:: bash + + # Old (4.x and earlier) + bin/cake migrations seed + bin/cake migrations seed --seed Articles + + # New (5.x and later) + bin/cake seeds run + bin/cake seeds run Articles + +The new commands are: + +- ``bin/cake seeds run`` - Run seed classes +- ``bin/cake seeds status`` - Show seed execution status +- ``bin/cake seeds reset`` - Reset seed execution tracking +- ``bin/cake bake seed`` - Generate new seed classes + +Maintaining Backward Compatibility +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you need to maintain the old command structure for existing scripts or CI/CD +pipelines, you can add command aliases in your application. In your +``src/Application.php`` file, add the following to the ``console()`` method: + +.. code-block:: php + + public function console(CommandCollection $commands): CommandCollection + { + // Add your application's commands + $commands = $this->addConsoleCommands($commands); + + // Add backward compatibility aliases for migrations 4.x commands + $commands->add('migrations seed', \Migrations\Command\SeedCommand::class); + + return $commands; + } + +For multiple aliases, you can add them all together: + +.. code-block:: php + + // Add multiple backward compatibility aliases + $commands->add('migrations seed', \Migrations\Command\SeedCommand::class); + $commands->add('migrations seed:run', \Migrations\Command\SeedCommand::class); + $commands->add('migrations seed:status', \Migrations\Command\SeedStatusCommand::class); + +This allows gradual migration of scripts and documentation without modifying the +migrations plugin or creating wrapper command classes. + +API Changes +----------- + If your migrations are using the ``AdapterInterface`` to fetch rows or update rows you will need to update your code. If you use ``Adapter::query()`` to execute queries, the return of this method is now @@ -43,16 +102,97 @@ Similar changes are for fetching a single row:: $stmt = $this->getAdapter()->query('SELECT * FROM articles'); $rows = $stmt->fetch('assoc'); -Problems with the new backend? -============================== +Unified Migrations Table +======================== + +As of migrations 5.x, there is a new unified ``cake_migrations`` table that +replaces the legacy ``phinxlog`` tables. This provides several benefits: + +- **Single table for all migrations**: Instead of separate ``phinxlog`` (app) + and ``{plugin}_phinxlog`` (plugins) tables, all migrations are tracked in + one ``cake_migrations`` table with a ``plugin`` column. +- **Simpler database schema**: Fewer migration tracking tables to manage. +- **Better plugin support**: Plugin migrations are properly namespaced. + +Backward Compatibility +---------------------- + +For existing applications with ``phinxlog`` tables: + +- **Automatic detection**: If any ``phinxlog`` table exists, migrations will + continue using the legacy tables automatically. +- **No forced migration**: Existing applications don't need to change anything. +- **Opt-in upgrade**: You can migrate to the new table when you're ready. + +Configuration +------------- + +The ``Migrations.legacyTables`` configuration option controls the behavior: + +.. code-block:: php + + // config/app.php or config/app_local.php + 'Migrations' => [ + // null (default): Autodetect - use legacy if phinxlog tables exist + // false: Force use of new cake_migrations table + // true: Force use of legacy phinxlog tables + 'legacyTables' => null, + ], + +Upgrading to the Unified Table +------------------------------ + +To migrate from ``phinxlog`` tables to the new ``cake_migrations`` table: + +1. **Preview the upgrade** (dry run): + + .. code-block:: bash + + bin/cake migrations upgrade --dry-run + +2. **Run the upgrade**: + + .. code-block:: bash + + bin/cake migrations upgrade + +3. **Update your configuration**: + + .. code-block:: php + + // config/app.php + 'Migrations' => [ + 'legacyTables' => false, + ], + +4. **Optionally drop phinx tables**: Your migration history is preserved + by default. Use ``--drop-tables`` to drop the ``phinxlog``tables after + verifying your migrations run correctly. + + .. code-block:: bash + + bin/cake migrations upgrade --drop-tables + +Rolling Back +------------ + +If you need to revert to phinx tables after upgrading: + +1. Set ``'legacyTables' => true`` in your configuration. + +.. warning:: + + You cannot rollback after running ``upgrade --drop-tables``. + + +New Installations +----------------- + +For new applications without any existing ``phinxlog`` tables, the unified +``cake_migrations`` table is used automatically. No configuration is needed. -The new backend is enabled by default. If your migrations contain errors when -run with the builtin backend, please open `an issue -`_. You can also switch back -to the ``phinx`` backend through application configuration. Add the -following to your ``config/app.php``:: +Problems with the builtin backend? +================================== - return [ - // Other configuration. - 'Migrations' => ['backend' => 'phinx'], - ]; +If your migrations contain errors when run with the builtin backend, please +open `an issue `_. diff --git a/docs/en/upgrading.rst b/docs/en/upgrading.rst new file mode 100644 index 000000000..23ab2e113 --- /dev/null +++ b/docs/en/upgrading.rst @@ -0,0 +1,140 @@ +Upgrading from 4.x to 5.x +######################### + +Migrations 5.x includes significant changes from 4.x. This guide outlines +the breaking changes and what you need to update when upgrading. + +Requirements +============ + +- **PHP 8.2+** is now required (was PHP 8.1+) +- **CakePHP 5.3+** is now required +- **Phinx has been removed** - The builtin backend is now the only supported backend + +If you were already using the builtin backend in 4.x (introduced in 4.3, default in 4.4), +the upgrade should be straightforward. See :doc:`upgrading-to-builtin-backend` for more +details on API differences between the phinx and builtin backends. + +Command Changes +=============== + +The phinx wrapper commands have been removed. The new command structure is: + +Migrations +---------- + +The migration commands remain unchanged: + +.. code-block:: bash + + bin/cake migrations migrate + bin/cake migrations rollback + bin/cake migrations status + bin/cake migrations mark_migrated + bin/cake migrations dump + +Seeds +----- + +Seed commands have changed: + +.. code-block:: bash + + # 4.x # 5.x + bin/cake migrations seed bin/cake seeds run + bin/cake migrations seed --seed X bin/cake seeds run X + +The new seed commands are: + +- ``bin/cake seeds run`` - Run seed classes +- ``bin/cake seeds run SeedName`` - Run a specific seed +- ``bin/cake seeds status`` - Show seed execution status +- ``bin/cake seeds reset`` - Reset seed execution tracking + +Maintaining Backward Compatibility +---------------------------------- + +If you need to maintain the old ``migrations seed`` command for existing scripts or +CI/CD pipelines, you can add command aliases in your ``src/Application.php``:: + + public function console(CommandCollection $commands): CommandCollection + { + $commands = $this->addConsoleCommands($commands); + + // Add backward compatibility alias + $commands->add('migrations seed', \Migrations\Command\SeedCommand::class); + + return $commands; + } + +Removed Classes and Namespaces +============================== + +The following have been removed in 5.x: + +- ``Migrations\Command\Phinx\*`` - All phinx wrapper commands +- ``Migrations\Command\MigrationsCommand`` - Use ``bin/cake migrations`` entry point +- ``Migrations\Command\MigrationsSeedCommand`` - Use ``bin/cake seeds run`` +- ``Migrations\Command\MigrationsCacheBuildCommand`` - Schema cache is managed differently +- ``Migrations\Command\MigrationsCacheClearCommand`` - Schema cache is managed differently +- ``Migrations\Command\MigrationsCreateCommand`` - Use ``bin/cake bake migration`` + +If you have code that directly references any of these classes, you will need to update it. + +API Changes +=========== + +Adapter Query Results +--------------------- + +If your migrations use ``AdapterInterface::query()`` to fetch rows, the return type has +changed from a phinx result to ``Cake\Database\StatementInterface``:: + + // 4.x (phinx) + $stmt = $this->getAdapter()->query('SELECT * FROM articles'); + $rows = $stmt->fetchAll(); + $row = $stmt->fetch(); + + // 5.x (builtin) + $stmt = $this->getAdapter()->query('SELECT * FROM articles'); + $rows = $stmt->fetchAll('assoc'); + $row = $stmt->fetch('assoc'); + +New Features in 5.x +=================== + +5.x includes several new features: + +Seed Tracking +------------- + +Seeds are now tracked in a ``cake_seeds`` table by default, preventing accidental re-runs. +Use ``--force`` to run a seed again, or ``bin/cake seeds reset`` to clear tracking. +See :doc:`seeding` for more details. + +Check Constraints +----------------- + +Support for database check constraints via ``addCheckConstraint()``. +See :doc:`writing-migrations` for usage details. + +MySQL ALTER Options +------------------- + +Support for ``ALGORITHM`` and ``LOCK`` options on MySQL ALTER TABLE operations, +allowing control over how MySQL performs schema changes. + +insertOrSkip() for Seeds +------------------------ + +New ``insertOrSkip()`` method for seeds to insert records only if they don't already exist, +making seeds more idempotent. + +Migration File Compatibility +============================ + +Your existing migration files should work without changes in most cases. The builtin backend +provides the same API as phinx for common operations. + +If you encounter issues with existing migrations, please report them at +https://github.com/cakephp/migrations/issues diff --git a/docs/en/writing-migrations.rst b/docs/en/writing-migrations.rst index 2952f949e..161c803dc 100644 --- a/docs/en/writing-migrations.rst +++ b/docs/en/writing-migrations.rst @@ -88,6 +88,7 @@ Migrations, and will be automatically reversed: - 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 @@ -286,6 +287,32 @@ 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: ======== =========== @@ -572,6 +599,252 @@ 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 -------------- @@ -705,7 +978,69 @@ You can limit the maximum length of a column by using the ``limit`` option:: Changing Column Attributes -------------------------- -To change column type or options on an existing column, use the ``changeColumn()`` method. +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'); - $users->changeColumn('email', 'string', ['limit' => 255]) + // Must specify all attributes + $users->changeColumn('email', 'string', [ + 'limit' => 255, + 'null' => true, + 'default' => null, + ]) ->save(); } @@ -733,6 +1073,20 @@ See :ref:`valid-column-types` and `Valid Column Options`_ for allowed values:: } } +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 -------------------- @@ -1159,11 +1513,24 @@ involved:: } } -Determining Whether a Table Exists ----------------------------------- +Working With Check Constraints +------------------------------- -You can determine whether or not a table exists by using the ``hasTable()`` -method:: +.. 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:: hasTable('users'); - if ($exists) { - // do something - } + $table = $this->table('products'); + $table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->addCheckConstraint('price_positive', 'price > 0') + ->save(); } /** @@ -1187,19 +1554,20 @@ method:: */ public function down(): void { - + $table = $this->table('products'); + $table->dropCheckConstraint('price_positive') + ->save(); } } -Dropping a Table ----------------- +The first argument is the constraint name, and the second is the SQL expression +that defines the constraint. The expression should evaluate to a boolean value. -Tables can be dropped quite easily using the ``drop()`` method. It is a -good idea to recreate the table again in the ``down()`` method. +Using the CheckConstraint Fluent Builder +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -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:: +For more complex scenarios, you can use the ``checkConstraint()`` method to get +a fluent builder:: 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]) + $table = $this->table('users'); + $table->addColumn('age', 'integer') + ->addColumn('status', 'string', ['limit' => 20]) + ->addCheckConstraint( + $this->checkConstraint() + ->setName('age_valid') + ->setExpression('age >= 18 AND age <= 120') + ) + ->addCheckConstraint( + $this->checkConstraint() + ->setName('status_valid') + ->setExpression("status IN ('active', 'inactive', 'pending')") + ) ->save(); } } -Renaming a Table ----------------- +Auto-Generated Constraint Names +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To rename a table access an instance of the Table object then call the -``rename()`` method:: +If you don't specify a constraint name, one will be automatically generated based +on the table name and expression hash:: table('users'); - $table - ->rename('legacy_users') - ->update(); - } - - /** - * Migrate Down. - */ - public function down(): void - { - $table = $this->table('legacy_users'); - $table - ->rename('users') - ->update(); + $table = $this->table('inventory'); + $table->addColumn('quantity', 'integer') + // Name will be auto-generated like 'inventory_chk_a1b2c3d4' + ->addCheckConstraint( + $this->checkConstraint() + ->setExpression('quantity >= 0') + ) + ->save(); } } -Changing the Primary Key ------------------------- +Complex Check Constraints +~~~~~~~~~~~~~~~~~~~~~~~~~~ -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:: +Check constraints can reference multiple columns and use complex SQL expressions:: table('users'); + $table = $this->table('date_ranges'); + $table->addColumn('start_date', 'date') + ->addColumn('end_date', 'date') + ->addColumn('discount', 'decimal', ['precision' => 5, 'scale' => 2]) + ->addCheckConstraint('valid_date_range', 'end_date >= start_date') + ->addCheckConstraint('valid_discount', 'discount BETWEEN 0 AND 100') + ->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_positive', 'price > 0') + ->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_positive', 'price > 0') + ->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]) @@ -1494,3 +2381,76 @@ 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/docs/fr/conf.py b/docs/fr/conf.py deleted file mode 100644 index b02032efa..000000000 --- a/docs/fr/conf.py +++ /dev/null @@ -1,9 +0,0 @@ -import sys, os - -# Append the top level directory of the docs, so we can import from the config dir. -sys.path.insert(0, os.path.abspath('..')) - -# Pull in all the configuration options defined in the global config file.. -from config.all import * - -language = 'fr' diff --git a/docs/fr/contents.rst b/docs/fr/contents.rst deleted file mode 100644 index 1459b1f1b..000000000 --- a/docs/fr/contents.rst +++ /dev/null @@ -1,5 +0,0 @@ -.. toctree:: - :maxdepth: 2 - :caption: CakePHP Migrations - - /index diff --git a/docs/fr/index.rst b/docs/fr/index.rst deleted file mode 100644 index 10fca16d4..000000000 --- a/docs/fr/index.rst +++ /dev/null @@ -1,1178 +0,0 @@ -Migrations -########## - -Migrations est un plugin supporté par la core team pour vous aider à gérer les -changements dans la base de données en écrivant des fichiers PHP qui peuvent -être versionnés par votre système de gestion de version. - -Il vous permet de faire évoluer vos tables au fil du temps. -Au lieu d'écrire vos modifications de schéma en SQL, ce plugin vous permet -d'utiliser un ensemble intuitif de méthodes qui facilite la mise en œuvre des -modifications au sein de la base de données. - -Ce plugin est un wrapper pour la librairie de gestion des migrations de bases de -données `Phinx `_. - -Installation -============ - -Par défaut Migrations est installé avec le squelette d’application. Si vous le -retirez et voulez le réinstaller, vous pouvez le faire en lançant ce qui suit à -partir du répertoire ROOT de votre application (où le fichier composer.json est -localisé): - -.. code-block:: bash - - php composer.phar require cakephp/migrations "@stable" - - # Ou si composer est installé globalement - - composer require cakephp/migrations "@stable" - -Pour utiliser le plugin, vous devrez le charger dans le fichier -**config/bootstrap.php** de votre application. -Vous pouvez utiliser `le shell de Plugin de CakePHP -`__ pour -charger et décharger les plugins de votre **config/bootstrap.php**: - -.. code-block:: bash - - bin/cake plugin load Migrations - -Ou vous pouvez charger le plugin en modifiant votre fichier -**config/bootstrap.php**, en ajoutant ce qui suit:: - - Plugin::load('Migrations'); - -De plus, vous devrez configurer la base de données par défaut pour votre -application dans le fichier **config/app.php** comme expliqué dans la section -sur la `configuration des bases de données -`__. - -Vue d'ensemble -============== - -Une migration est simplement un fichier PHP qui décrit les changements à -effectuer sur la base de données. Un fichier de migration peut créer ou -supprimer des tables, ajouter ou supprimer des colonnes, créer des index et même -insérer des données dans votre base de données. - -Ci-dessous un exemple de migration:: - - table('products'); - $table->addColumn('name', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]); - $table->addColumn('description', 'text', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('created', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('modified', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->create(); - } - } - -Cette migration va ajouter une table à votre base de données nommée ``products`` -avec les définitions de colonne suivantes: - -- ``id`` colonne de type ``integer`` comme clé primaire -- ``name`` colonne de type ``string`` -- ``description`` colonne de type ``text`` -- ``created`` colonne de type ``datetime`` - -.. tip:: - - La colonne avec clé primaire nommée ``id`` sera ajoutée **implicitement**. - -.. note:: - - Notez que ce fichier décrit ce à quoi la base de données devrait ressembler - **après** l'application de la migration. À ce stade la table ``products`` - n'existe pas dans votre base de données, nous avons simplement créé un - fichier qui est à la fois capable de créer la table ``products`` avec les - bonnes colonnes mais aussi de supprimer la table quand une opération de - ``rollback`` (retour en arrière) de la migration est effectuée. - -Une fois que le fichier a été créé dans le dossier **config/Migrations**, vous -pourrez exécuter la commande ``migrations`` suivante pour créer la table dans -votre base de données: - -.. code-block:: bash - - bin/cake migrations migrate - -La commande ``migrations`` suivante va effectuer un ``rollback`` (retour en -arrière) et supprimer la table de votre base de données: - -.. code-block:: bash - - bin/cake migrations rollback - -Création de Migrations -====================== - -Les fichiers de migrations sont stockés dans le répertoire **config/Migrations** -de votre application. Le nom des fichiers de migration est précédé de la -date/heure du jour de création, dans le format -**YYYYMMDDHHMMSS_MigrationName.php**. -Voici quelques exemples de noms de fichiers de migration: - -* 20160121163850_CreateProducts.php -* 20160210133047_AddRatingToProducts.php - -La meilleure façon de créer un fichier de migration est d'utiliser la ligne de -commande ``bin/cake bake migration``. - -Assurez-vous de bien lire la `documentation officielle de Phinx `_ afin de connaître la liste -complète des méthodes que vous pouvez utiliser dans l'écriture des fichiers de -migration. - -.. note:: - - Quand vous utilisez l'option ``bake``, vous pouvez toujours modifier la - migration avant de l'exécuter si besoin. - -Syntaxe -------- - -La syntaxe de la commande ``bake`` est de la forme suivante: - -.. code-block:: bash - - bin/cake bake migration CreateProducts name:string description:text created modified - -Quand vous utilisez ``bake`` pour créer des tables, ajouter des colonnes ou -effectuer diverses opérations sur votre base de données, vous devez en général -fournir deux choses: - -* le nom de la migration que vous allez générer (``CreateProducts`` dans notre - exemple) -* les colonnes de la table qui seront ajoutées ou retirées dans la migration - (``name:string description:text created modified`` dans notre exemple) - -Étant données les conventions, tous les changements de schéma ne peuvent pas -être effectuées avec les commandes shell. - -De plus, vous pouvez créer un fichier de migration vide si vous voulez un -contrôle total sur ce qui doit être executé, en ne spécifiant pas de définition -de colonnes: - -.. code-block:: bash - - bin/cake migrations create MyCustomMigration - -Nom de Fichier des Migrations -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Les noms des migrations peuvent suivre l'une des structures suivantes: - -* (``/^(Create)(.*)/``) Crée la table spécifiée. -* (``/^(Drop)(.*)/``) Supprime la table spécifiée. Ignore les arguments de champ spécifié. -* (``/^(Add).*(?:To)(.*)/``) Ajoute les champs à la table spécifiée. -* (``/^(Remove).*(?:From)(.*)/``) Supprime les champs de la table spécifiée. -* (``/^(Alter)(.*)/``) Modifie la table spécifiée. Un alias pour CreateTable et AddField. -* (``/^(Alter).*(?:On)(.*)/``) Modifie les champs de la table spécifiée. - -Vous pouvez aussi utiliser ``la_forme_avec_underscores`` comme nom pour vos -migrations par exemple ``create_products``. - -.. versionadded:: cakephp/migrations 1.5.2 - - Depuis la version 1.5.2 du `plugin migrations `_, - le nom de fichier de migration sera automatiquement avec des majuscules. - Cette version du plugin est seulement disponible pour une version de - CakePHP >= to 3.1. Avant cette version du plugin, le nom des migrations - serait sous la forme avec des underscores, par exemple - ``20160121164955_create_products.php``. - -.. warning:: - - Les noms des migrations sont utilisés comme noms de classe de migration, et - peuvent donc être en conflit avec d'autres migrations si les noms de classe - ne sont pas uniques. Dans ce cas, il peut être nécessaire de remplacer - manuellement le nom plus tard, ou simplement changer le nom - que vous avez spécifié. - -Définition de Colonnes -~~~~~~~~~~~~~~~~~~~~~~ - -Quand vous définissez des colonnes avec la ligne de commande, il peut être -pratique de se souvenir qu'elles suivent le modèle suivant:: - - fieldName:fieldType?[length]:indexType:indexName - -Par exemple, les façons suivantes sont toutes des façons valides pour spécifier -un champ d'email: - -* ``email:string?`` -* ``email:string:unique`` -* ``email:string?[50]`` -* ``email:string:unique:EMAIL_INDEX`` -* ``email:string[120]:unique:EMAIL_INDEX`` - -Le point d'interrogation qui suit le type du champ entrainera que la colonne -peut être null. - -Le paramètre ``length`` pour ``fieldType`` est optionnel et doit toujours être -écrit entre crochets. - -Les champs nommés ``created`` et ``modified``, tout comme les champs ayant pour -suffixe ``_at``, vont automatiquement être définis avec le type ``datetime``. - -Les types de champ sont ceux qui sont disponibles avec la librairie ``Phinx``. -Ce sont les suivants: - -* string -* text -* integer -* biginteger -* float -* decimal -* datetime -* timestamp -* time -* date -* binary -* boolean -* uuid - -Il existe quelques heuristiques pour choisir les types de champ quand ils ne -sont pas spécifiés ou définis avec une valeur invalide. Par défaut, le type est -``string``: - -* id: integer -* created, modified, updated: datetime - -Créer une Table ---------------- - -Vous pouvez utiliser ``bake`` pour créer une table: - -.. code-block:: bash - - bin/cake bake migration CreateProducts name:string description:text created modified - -La ligne de commande ci-dessus va générer un fichier de migration qui ressemble -à:: - - table('products'); - $table->addColumn('name', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]); - $table->addColumn('description', 'text', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('created', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('modified', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->create(); - } - } - -Ajouter des Colonnes à une Table Existante ------------------------------------------- - -Si le nom de la migration dans la ligne de commande est de la forme -"AddXXXToYYY" et est suivie d'une liste de noms de colonnes et de types alors -un fichier de migration contenant le code pour la création des colonnes sera -généré: - -.. code-block:: bash - - bin/cake bake migration AddPriceToProducts price:decimal - -L'exécution de la ligne de commande ci-dessus va générer:: - - table('products'); - $table->addColumn('price', 'decimal') - ->update(); - } - } - -Ajouter un Index de Colonne à une Table ---------------------------------------- - -Il est également possible d'ajouter des indexes de colonnes: - -.. code-block:: bash - - bin/cake bake migration AddNameIndexToProducts name:string:index - -va générer:: - - table('products'); - $table->addColumn('name', 'string') - ->addIndex(['name']) - ->update(); - } - } - -Spécifier la Longueur d'un Champ --------------------------------- - -.. versionadded:: cakephp/migrations 1.4 - -Si vous voulez spécifier une longueur de champ, vous pouvez le faire entre -crochets dans le type du champ, par exemple: - -.. code-block:: bash - - bin/cake bake migration AddFullDescriptionToProducts full_description:string[60] - -L'exécution de la ligne de commande ci-dessus va générer:: - - table('products'); - $table->addColumn('full_description', 'string', [ - 'default' => null, - 'limit' => 60, - 'null' => false, - ]) - ->update(); - } - } - -Si aucune longueur n'est spécifiée, les longueurs pour certain types de -colonnes sont par défaut: - -* string: 255 -* integer: 11 -* biginteger: 20 - -Modifier une colonne d'une table ------------------------------------ - -De la même manière, vous pouvez générer une migration pour modifier une colonne à l'aide de la commande -ligne de commande, si le nom de la migration est de la forme "AlterXXXOnYYY": - -.. code-block:: bash - - bin/cake bake migration AlterPriceOnProducts name:float - -créé le fichier:: - - table('products'); - $table->changeColumn('name', 'float'); - $table->update(); - } - } - -Retirer une Colonne d'une Table -------------------------------- - -De la même façon, vous pouvez générer une migration pour retirer une colonne -en utilisant la ligne de commande, si le nom de la migration est de la forme -"RemoveXXXFromYYY": - -.. code-block:: bash - - bin/cake bake migration RemovePriceFromProducts price - -créé le fichier:: - - table('products'); - $table->removeColumn('price') - ->save(); - } - } - -.. note:: - - La commande `removeColumn` n'est pas réversible, donc elle doit être appelée - dans la méthode `up`. Un appel correspondant au `addColumn` doit être - ajouté à la méthode `down`. - -Générer une Migration à partir d'une Base de Données Existante -============================================================== - -Si vous avez affaire à une base de données pré-existante et que vous voulez -commencer à utiliser migrations, ou que vous souhaitez versionner le schéma -initial de votre base de données, vous pouvez exécuter la commande -``migration_snapshot``: - -.. code-block:: bash - - bin/cake bake migration_snapshot Initial - -Elle va générer un fichier de migration appelé **Initial** contenant toutes les -déclarations pour toutes les tables de votre base de données. - -Par défaut, le snapshot va être créé en se connectant à la base de données -définie dans la configuration de la connection ``default``. -Si vous devez créer un snapshot à partir d'une autre source de données, vous -pouvez utiliser l'option ``--connection``: - -.. code-block:: bash - - bin/cake bake migration_snapshot Initial --connection my_other_connection - -Vous pouvez aussi vous assurer que le snapshot inclut seulement les tables pour -lesquelles vous avez défini les classes de model correspondantes en utilisant -le flag ``--require-table``: - -.. code-block:: bash - - bin/cake bake migration_snapshot Initial --require-table - -Quand vous utilisez le flag ``--require-table``, le shell va chercher les -classes ``Table`` de votre application et va seulement ajouter les tables de -model dans le snapshot. - -La même logique sera appliquée implicitement si vous souhaitez créer un -snapshot pour un plugin. Pour ce faire, vous devez utiliser l'option -``--plugin``: - -.. code-block:: bash - - bin/cake bake migration_snapshot Initial --plugin MyPlugin - -Seules les tables ayant une classe d'un objet model ``Table`` définie seront -ajoutées au snapshot de votre plugin. - -.. note:: - - Quand vous créez un snapshot pour un plugin, les fichiers de migration sont - créés dans le répertoire **config/Migrations** de votre plugin. - -Notez que quand vous créez un snapshot, il est automatiquement marqué dans la -table de log de phinx comme migré. - -Générer un diff entre deux états de base de données -=================================================== - -.. versionadded:: cakephp/migrations 1.6.0 - -Vous pouvez générer un fichier de migrations qui regroupera toutes les -différences entre deux états de base de données en utilisant le template bake -``migration_diff``. Pour cela, vous pouvez utiliser la commande suivante: - -.. code-block:: bash - - bin/cake bake migration_diff NameOfTheMigrations - -Pour avoir un point de comparaison avec l'état actuel de votre base de données, -le shell migrations va générer, après chaque appel de ``migrate`` ou -``rollback`` un fichier "dump". Ce fichier dump est un fichier qui contient -l'ensemble de l'état de votre base de données à un point précis dans le temps. - -Quand un fichier dump a été généré, toutes les modifications que vous ferez -directement dans votre SGBD seront ajoutées au fichier de migration qui sera -généré quand vous appelerez la commande ``bake migration_diff``. - -Par défaut, le diff sera fait en se connectant à la base de données définie -dans votre configuration de Connection ``default``. -Si vous avez besoin de faire un diff depuis une source différente, vous pouvez -utiliser l'option ``--connection``: - -.. code-block:: bash - - bin/cake bake migration_diff NameOfTheMigrations --connection my_other_connection - -Si vous souhaitez utiliser la fonctionnalité de diff sur une application qui -possède déjà un historique de migrations, vous allez avoir besoin de créer le -fichier dump manuellement pour qu'il puisse être utilisé comme point de -comparaison: - -.. code-block:: bash - - bin/cake migrations dump - -L'état de votre base de données devra être le même que si vous aviez migré tous -vos fichiers de migrations avant de créer le fichier dump. -Une fois que le fichier dump est créé, vous pouvez opérer des changements dans -votre base de données et utiliser la commande ``bake migration_diff`` quand -vous voulez - -.. note:: - - Veuillez noter que le système n'est pas capable de détecter les colonnes - renommées. - -Les Commandes -============= - -``migrate`` : Appliquer les Migrations --------------------------------------- - -Une fois que vous avez généré ou écrit votre fichier de migration, vous devez -exécuter la commande suivante pour appliquer les modifications à votre base de -données: - -.. code-block:: bash - - # Exécuter toutes les migrations - bin/cake migrations migrate - - # Pour migrer vers une version spécifique, utilisez - # le paramètre ``--target`` ou -t (version courte) - # Cela correspond à l'horodatage qui est ajouté au début - # du nom de fichier des migrations. - bin/cake migrations migrate -t 20150103081132 - - # Par défaut, les fichiers de migration se trouvent dans - # le répertoire **config/Migrations**. Vous pouvez spécifier le répertoire - # en utilisant l'option ``--source`` ou ``-s`` (version courte). - # L'exemple suivant va exécuter les migrations - # du répertoire **config/Alternate** - bin/cake migrations migrate -s Alternate - - # Vous pouvez exécuter les migrations avec une connection différente - # de celle par défaut ``default`` en utilisant l'option ``--connection`` - # ou ``-c`` (version courte) - bin/cake migrations migrate -c my_custom_connection - - # Les migrations peuvent aussi être exécutées pour les plugins. Utilisez - # simplement l'option ``--plugin`` ou ``-p`` (version courte) - bin/cake migrations migrate -p MyAwesomePlugin - -``rollback`` : Annuler les Migrations -------------------------------------- - -La commande de restauration est utilisée pour annuler les précédentes migrations -réalisées par ce plugin. C'est l'inverse de la commande ``migrate``.: - -.. code-block:: bash - - # Vous pouvez annuler la migration précédente en utilisant - # la commande ``rollback``:: - bin/cake migrations rollback - - # Vous pouvez également passer un numéro de version de migration - # pour revenir à une version spécifique:: - bin/cake migrations rollback -t 20150103081132 - -Vous pouvez aussi utilisez les options ``--source``, ``--connection`` et -``--plugin`` comme pour la commande ``migrate``. - -``status`` : Statuts de Migrations ----------------------------------- - -La commande ``status`` affiche une liste de toutes les migrations, ainsi que -leur état actuel. Vous pouvez utiliser cette commande pour déterminer les -migrations qui ont été exécutées: - -.. code-block:: bash - - bin/cake migrations status - -Vous pouvez aussi afficher les résultats avec le format JSON en utilisant -l'option ``--format`` (ou ``-f`` en raccourci): - -.. code-block:: bash - - bin/cake migrations status --format json - -Vous pouvez aussi utiliser les options ``--source``, ``--connection`` et -``--plugin`` comme pour la commande ``migrate``. - -``mark_migrated`` : Marquer une Migration en Migrée ---------------------------------------------------- - -.. versionadded:: 1.4.0 - -Il peut parfois être utile de marquer une série de migrations comme "migrées" -sans avoir à les exécuter. -Pour ce faire, vous pouvez utiliser la commande ``mark_migrated``. -Cette commande fonctionne de la même manière que les autres commandes. - -Vous pouvez marquer toutes les migrations comme migrées en utilisant cette -commande: - -.. code-block:: bash - - bin/cake migrations mark_migrated - -Vous pouvez également marquer toutes les migrations jusqu'à une version -spécifique en utilisant l'option ``--target``: - -.. code-block:: bash - - bin/cake migrations mark_migrated --target=20151016204000 - -Si vous ne souhaitez pas que la migration "cible" soit marquée, vous pouvez -utiliser le _flag_ ``--exclude``: - -.. code-block:: bash - - bin/cake migrations mark_migrated --target=20151016204000 --exclude - -Enfin, si vous souhaitez marquer seulement une migration, vous pouvez utiliser -le _flag_ ``--only``: - -.. code-block:: bash - - bin/cake migrations mark_migrated --target=20151016204000 --only - -Vous pouvez aussi utilisez les options ``--source``, ``--connection`` et -``--plugin`` comme pour la commande ``migrate``. - -.. note:: - - Lorsque vous créez un snapshot avec la commande - ``cake bake migration_snapshot``, la migration créée sera automatiquement - marquée comme "migrée". - -.. deprecated:: 1.4.0 - - Les instructions suivantes ont été dépréciées. Utilisez les seulement si - vous utilisez une version du plugin inférieure à 1.4.0. - -La commande attend le numéro de version de la migration comme argument: - -.. code-block:: bash - - bin/cake migrations mark_migrated 20150420082532 - -Si vous souhaitez marquer toutes les migrations comme "migrées", vous pouvez -utiliser la valeur spéciale ``all``. Si vous l'utilisez, toutes les migrations -trouvées seront marquées comme "migrées": - -.. code-block:: bash - - bin/cake migrations mark_migrated all - -``seed`` : Remplir votre Base de Données (Seed) ------------------------------------------------ - -Depuis la version 1.5.5, vous pouvez utiliser le shell ``migrations`` pour -remplir votre base de données. Cela vient de la `fonctionnalité de seed -de la librairie Phinx `_. -Par défaut, les fichiers de seed vont être recherchés dans le répertoire -``config/Seeds`` de votre application. Assurez-vous de suivre les -`instructions de Phinx pour construire les fichiers de seed `_. - -En ce qui concerne migrations, une interface ``bake`` est fournie pour les -fichiers de seed: - -.. code-block:: bash - - # Ceci va créer un fichier ArticlesSeed.php dans le répertoire config/Seeds - # de votre application - # Par défaut, la table que le seed va essayer de modifier est la version - # "tableized" du nom de fichier du seed - bin/cake bake seed Articles - - # Vous spécifiez le nom de la table que les fichiers de seed vont modifier - # en utilisant l'option ``--table`` - bin/cake bake seed Articles --table my_articles_table - - # Vous pouvez spécifier un plugin dans lequel faire la création - bin/cake bake seed Articles --plugin PluginName - - # Vous pouvez spécifier une connection alternative quand vous générez un - # seeder. - bin/cake bake seed Articles --connection connection - -.. versionadded:: cakephp/migrations 1.6.4 - - Les options ``--data``, ``--limit`` and ``--fields`` ont été ajoutées pour - permettre d'exporter des données extraites depuis votre base de données. - -A partir de 1.6.4, la commande ``bake seed`` vous permet de créer des fichiers -de seed avec des lignes exportées de votre base de données en utilisant -l'option ``--data``: - -.. code-block:: bash - - bin/cake bake seed --data Articles - -Par défaut, cela exportera toutes les lignes trouvées dans la table. Vous -pouvez limiter le nombre de lignes exportées avec l'option ``--limit``: - -.. code-block:: bash - - # N'exportera que les 10 premières lignes trouvées - bin/cake bake seed --data --limit 10 Articles - -Si vous ne souhaitez inclure qu'une sélection des champs de la table dans votre -fichier de seed, vous pouvez utiliser l'option ``--fields``. Elle prend la -liste des champs séparés par une virgule comme argument: - -.. code-block:: bash - - # N'exportera que les champs `id`, `title` et `excerpt` - bin/cake bake seed --data --fields id,title,excerpt Articles - -.. tip:: - - Vous pouvez bien sûr utiliser les options ``--limit`` et ``--fields`` - ensemble dans le même appel. - -Pour faire un seed de votre base de données, vous pouvez utiliser la -sous-commande ``seed``: - -.. code-block:: bash - - # Sans paramètres, la sous-commande seed va exécuter tous les seeders - # disponibles du répertoire cible, dans l'ordre alphabétique. - bin/cake migrations seed - - # Vous pouvez spécifier seulement un seeder à exécuter en utilisant - # l'option `--seed` - bin/cake migrations seed --seed ArticlesSeed - - # Vous pouvez exécuter les seeders d'un autre répertoire - bin/cake migrations seed --source AlternativeSeeds - - # Vous pouvez exécuter les seeders d'un plugin - bin/cake migrations seed --plugin PluginName - - # Vous pouvez exécuter les seeders d'une connection spécifique - bin/cake migrations seed --connection connection - -Notez que, à l'opposé des migrations, les seeders ne sont pas suivies, ce qui -signifie que le même seeder peut être appliqué plusieurs fois. - -Appeler un Seeder depuis un autre Seeder -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. versionadded:: cakephp/migrations 1.6.2 - -Généralement, quand vous remplissez votre base de données avec des *seeders*, -l'ordre dans lequel vous faites les insertions est important pour éviter de -rencontrer des erreurs dûes à des *constraints violations*. -Puisque les *seeders* sont exécutés dans l'ordre alphabétique par défaut, vous -pouvez utiliser la méthode ``\Migrations\AbstractSeed::call()`` pour définir -votre propre séquence d'exécution de *seeders*:: - - use Migrations\AbstractSeed; - - class DatabaseSeed extends AbstractSeed - { - public function run(): void - { - $this->call('AnotherSeed'); - $this->call('YetAnotherSeed'); - - // Vous pouvez utiliser la syntaxe "plugin" pour appeler un seeder - // d'un autre plugin - $this->call('PluginName.FromPluginSeed'); - } - } - -.. note:: - - Assurez vous d'*extend* la classe du plugin Migrations ``AbstractSeed`` si - vous voulez pouvoir utiliser la méthode ``call()``. Cette classe a été - ajoutée dans la version 1.6.2. - -``dump`` : Générer un fichier dump pour la fonctionnalité de diff ------------------------------------------------------------------ - -La commande Dump crée un fichier qui sera utilisé avec le template bake -``migration_diff``: - -.. code-block:: bash - - bin/cake migrations dump - -Chaque fichier dump généré est spécifique à la _Connection_ par laquelle il a -été générée (le nom du fichier est suffixé par ce nom). Cela permet à la -commande ``bake migration_diff`` de calculer le diff correctement dans le cas -où votre application gérerait plusieurs bases de données (qui pourraient être -basées sur plusieurs SGDB. - -Les fichiers de dump sont créés dans le même dossier que vos fichiers de -migrations. - -Vous pouvez aussi utiliser les options ``--source``, ``--connection`` et -``--plugin`` comme pour la commande ``migrate``. - - -Utiliser Migrations dans les Tests -================================== - -Si votre application fait usage des migrations, vous pouvez ré-utiliser -celles-ci afin de maintenir le schéma de votre base de données de test. Dans -le fichier ``tests/bootstrap.php``, vous pouvez utiliser la -classe ``Migrator`` pour construire le schéma avant que vos tests ne soient lancés. -La classe ``Migrator`` réutilisera le schéma existant si il correspond à vos migrations. -Si vos migrations ont évolué depuis le dernier lancement de vos tests, toutes les -tables des connections de test concernées seront effacées et les migrations seront relancées -afin d'actualiser le schéma:: - - // dans tests/bootstrap.php - use Migrations\TestSuite\Migrator; - - $migrator = new Migrator(); - - // Simple setup sans plugins - $migrator->run(); - - // Setup sur une base de données autre que 'test' - $migrator->run(['connection' => 'test_other']); - - // Setup pour un plugin - $migrator->run(['plugin' => 'Contacts']); - - // Lancer les migrations du plugin Documents sur la connection test_docs. - $migrator->run(['plugin' => 'Documents', 'connection' => 'test_docs']); - - -Si vos migrations se trouvent à différents endroits, celles-ci doivent être executées ainsi:: - - // Migrations du plugin Contacts sur la connection ``test``, et du plugin Documents sur la connection ``test_docs`` - $migrator->runMany([ - ['plugin' => 'Contacts'], - ['plugin' => 'Documents', 'connection' => 'test_docs'] - ]); - -Les informations relatives au status des migrations de test sont rapportées dans les logs de l'application. - -.. versionadded: 3.2.0 - Migrator was added to complement the new fixtures in CakePHP 4.3.0. - -Utiliser Migrations dans les Plugins -==================================== - -Les plugins peuvent également contenir des fichiers de migration. Cela rend les -plugins destinés à la communauté beaucoup plus portable et plus facile à -installer. Toutes les commandes du plugin Migrations supportent l'option -``--plugin`` ou ``-p`` afin d'exécuter les commandes par rapport à ce plugin: - -.. code-block:: bash - - bin/cake migrations status -p PluginName - - bin/cake migrations migrate -p PluginName - -Effectuer des Migrations en dehors d'un environnement Console -============================================================= - -.. versionadded:: cakephp/migrations 1.2.0 - -Depuis la sortie de la version 1.2 du plugin migrations, vous pouvez effectuer -des migrations en dehors d'un environnement Console, directement depuis une -application, en utilisant la nouvelle classe ``Migrations``. -Cela peut être pratique si vous développez un installeur de plugins pour un CMS -par exemple. -La classe ``Migrations`` vous permet de lancer les commandes de la console de -migrations suivantes: - -* migrate -* rollback -* markMigrated -* status -* seed - -Chacune de ces commandes possède une méthode définie dans la classe -``Migrations``. - -Voici comment l'utiliser:: - - use Migrations\Migrations; - - $migrations = new Migrations(); - - // Va retourner un tableau des migrations et leur statut - $status = $migrations->status(); - - // Va retourner true en cas de succès. Si une erreur se produit, une exception est lancée - $migrate = $migrations->migrate(); - - // Va retourner true en cas de succès. Si une erreur se produit, une exception est lancée - $rollback = $migrations->rollback(); - - // Va retourner true en cas de succès. Si une erreur se produit, une exception est lancée - $markMigrated = $migrations->markMigrated(20150804222900); - - // Va retourner true en cas de succès. Su une erreur se produit, une exception est lancée - $seeded = $migrations->seed(); - -Ces méthodes acceptent un tableau de paramètres qui doivent correspondre aux -options de chacune des commandes:: - - use Migrations\Migrations; - - $migrations = new Migrations(); - - // Va retourner un tableau des migrations et leur statut - $status = $migrations->status(['connection' => 'custom', 'source' => 'MyMigrationsFolder']); - -Vous pouvez passer n'importe quelle option que la commande de la console -accepterait. -La seule exception étant la commande ``markMigrated`` qui attend le numéro de -version de la migration à marquer comme "migrée" comme premier argument. -Passez le tableau de paramètres en second argument pour cette méthode. - -En option, vous pouvez passer ces paramètres au constructeur de la classe. -Ils seront utilisés comme paramètres par défaut et vous éviteront ainsi d'avoir -à les passer à chaque appel de méthode:: - - use Migrations\Migrations; - - $migrations = new Migrations(['connection' => 'custom', 'source' => 'MyMigrationsFolder']); - - // Tous les appels suivant seront faits avec les paramètres passés au constructeur de la classe Migrations - $status = $migrations->status(); - $migrate = $migrations->migrate(); - -Si vous avez besoin d'écraser un ou plusieurs paramètres pour un appel, vous -pouvez les passer à la méthode:: - - use Migrations\Migrations; - - $migrations = new Migrations(['connection' => 'custom', 'source' => 'MyMigrationsFolder']); - - // Cet appel sera fait avec la connexion "custom" - $status = $migrations->status(); - // Cet appel avec la connexion "default" - $migrate = $migrations->migrate(['connection' => 'default']); - -Trucs et Astuces -================ - -Créer des Clés Primaires Personnalisées ---------------------------------------- - -Pour personnaliser la création automatique de la clé primaire ``id`` lors -de l'ajout de nouvelles tables, vous pouvez utiliser le deuxième argument de la -méthode ``table()``:: - - table('products', ['id' => false, 'primary_key' => ['id']]); - $table - ->addColumn('id', 'uuid') - ->addColumn('name', 'string') - ->addColumn('description', 'text') - ->create(); - } - } - -Le code ci-dessus va créer une colonne ``CHAR(36)`` ``id`` également utilisée -comme clé primaire. - -.. note:: - - Quand vous spécifiez une clé primaire personnalisée avec les lignes de - commande, vous devez la noter comme clé primaire dans le champ id, - sinon vous obtiendrez une erreur de champs id dupliqués, par exemple: - - .. code-block:: bash - - bin/cake bake migration CreateProducts id:uuid:primary name:string description:text created modified - -Depuis Migrations 1.3, une nouvelle manière de gérer les clés primaires a été -introduite. Pour l'utiliser, votre classe de migration devra étendre la -nouvelle classe ``Migrations\AbstractMigration``. -Vous pouvez définir la propriété ``autoId`` à ``false`` dans la classe de -Migration, ce qui désactivera la création automatique de la colonne ``id``. -Vous aurez cependant besoin de manuellement créer la colonne qui servira de clé -primaire et devrez l'ajouter à la déclaration de la table:: - - table('products'); - $table - ->addColumn('id', 'integer', [ - 'autoIncrement' => true, - 'limit' => 11 - ]) - ->addPrimaryKey('id') - ->addColumn('name', 'string') - ->addColumn('description', 'text') - ->create(); - } - } - -Comparée à la méthode précédente de gestion des clés primaires, cette méthode -vous donne un plus grand contrôle sur la définition de la colonne de la clé -primaire : signée ou non, limite, commentaire, etc. - -Toutes les migrations et les snapshots créés avec ``bake`` utiliseront cette -nouvelle méthode si nécessaire. - -.. warning:: - - Gérer les clés primaires ne peut être fait que lors des opérations de - créations de tables. Ceci est dû à des limitations pour certains serveurs - de base de données supportés par le plugin. - -Collations ----------- - -Si vous avez besoin de créer une table avec une ``collation`` différente -de celle par défaut de la base de données, vous pouvez la définir comme option -de la méthode ``table()``:: - - table('categories', [ - 'collation' => 'latin1_german1_ci' - ]) - ->addColumn('title', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]) - ->create(); - } - } - -Notez cependant que ceci ne peut être fait qu'en cas de création de table : -il n'y a actuellement aucun moyen d'ajouter une colonne avec une ``collation`` -différente de celle de la table ou de la base de données. -Seuls ``MySQL`` et ``SqlServer`` supportent cette option de configuration pour -le moment. - -Mettre à jour les Noms de Colonne et Utiliser les Objets Table --------------------------------------------------------------- - -Si vous utilisez un objet Table de l'ORM de CakePHP pour manipuler des valeurs -de votre base de données, comme renommer ou retirer une colonne, assurez-vous -de créer une nouvelle instance de votre objet Table après l'appel à -``update()``. Le registre de l'objet Table est nettoyé après un appel à -``update()`` afin de rafraîchir le schéma qui est reflèté et stocké dans l'objet -Table lors de l'instanciation. - -Migrations et déploiement -------------------------- -Si vous utilisez le plugin dans vos processus de déploiement, assurez-vous de -vider le cache de l'ORM pour qu'il renouvelle les _metadata_ des colonnes de vos -tables. -Autrement, vous pourrez rencontrer des erreurs de colonnes inexistantes quand -vous effectuerez des opérations sur vos nouvelles colonnes. -Le Core de CakePHP inclut un `Shell de Cache du Schéma -`__ que vous pouvez -utilisez pour vider le cache: - -.. code-block:: bash - - // Avant 3.6, utilisez orm_cache - bin/cake schema_cache clear - -Veuillez vous référer à la section du cookbook à propos du `Shell du Cache du Schéma -`__ si vous voulez -plus de détails à propos de ce shell. - -Renommer une table ------------------- - -Le plugin vous donne la possibilité de renommer une table en utilisant la -méthode ``rename()``. -Dans votre fichier de migration, vous pouvez utiliser la syntaxe suivante:: - - public function up(): void - { - $this->table('old_table_name') - ->rename('new_table_name'); - } - -Ne pas générer le fichier ``schema.lock`` ------------------------------------------ - -.. versionadded:: cakephp/migrations 1.6.5 - -Pour que la fonctionnalité de "diff" fonctionne, un fichier **.lock** est -généré à chaque que vous faites un migrate, un rollback ou que vous générez un -snapshot via bake pour permettre de suivre l'état de votre base de données à -n'importe quel moment. Vous pouvez empêcher que ce fichier ne soit généré, -comme par exemple lors d'un déploiement sur votre environnement de production, -en utilisant l'option ``--no-lock`` sur les commandes mentionnées ci-dessus: - -.. code-block:: bash - - bin/cake migrations migrate --no-lock - - bin/cake migrations rollback --no-lock - - bin/cake bake migration_snapshot MyMigration --no-lock diff --git a/docs/ja/conf.py b/docs/ja/conf.py deleted file mode 100644 index 5871da648..000000000 --- a/docs/ja/conf.py +++ /dev/null @@ -1,9 +0,0 @@ -import sys, os - -# Append the top level directory of the docs, so we can import from the config dir. -sys.path.insert(0, os.path.abspath('..')) - -# Pull in all the configuration options defined in the global config file.. -from config.all import * - -language = 'ja' diff --git a/docs/ja/contents.rst b/docs/ja/contents.rst deleted file mode 100644 index 1459b1f1b..000000000 --- a/docs/ja/contents.rst +++ /dev/null @@ -1,5 +0,0 @@ -.. toctree:: - :maxdepth: 2 - :caption: CakePHP Migrations - - /index diff --git a/docs/ja/index.rst b/docs/ja/index.rst deleted file mode 100644 index cc7685dd2..000000000 --- a/docs/ja/index.rst +++ /dev/null @@ -1,1052 +0,0 @@ -Migrations -########## - -マイグレーションは、バージョン管理システムを使用して追跡することができる PHP ファイルを -記述することによって、あなたのデータベースのスキーマ変更を行うための、コアチームによって -サポートされているプラグインです。 - -それはあなたが時間をかけてあなたのデータベーステーブルを進化させることができます。 -スキーマ変更の SQL を書く代わりに、このプラグインでは、直観的にデータベースの変更を -実現するための手段を使用することができます。 - -このプラグインは、データベースマイグレーションライブラリーの -`Phinx `_ のラッパーです。 - -インストール -============ - -初期状態で Migrations は、デフォルトのアプリケーションの雛形と一緒にインストールされます。 -もしあなたがそれを削除して再インストールしたい場合は、(composer.json ファイルが -配置されている)アプリケーションルートディレクトリーから次のコマンドを実行します。 - -.. code-block:: bash - - php composer.phar require cakephp/migrations "@stable" - - # また、composer がグローバルにインストールされていた場合は、 - - composer require cakephp/migrations "@stable" - -このプラグインを使用するためには、あなたは、アプリケーションの **config/bootstrap.php** -ファイルでロードする必要があります。あなたの **config/bootstrap.php** からプラグインを -ロード・アンロードするために `CakePHP の Plugin シェル -`__ -が利用できます。 : - -.. code-block:: bash - - bin/cake plugin load Migrations - -もしくは、あなたの **src/Application.php** ファイルを編集し、次の行を追加することで -ロードすることができます。 :: - - $this->addPlugin('Migrations'); - - // 3.6.0 より前は Plugin::load() を使用する必要があります - -また、 `データベース設定 -`__ の項で説明したように、 -あなたの **config/app.php** ファイル内のデフォルトのデータベース構成を設定する必要が -あります。 - -概要 -==== - -マイグレーションは、基本的にはデータベースの変更の操作を PHP ファイルで表します。 -マイグレーションファイルはテーブルを作成し、カラムの追加や削除、インデックスの作成や -データの作成さえ可能です。 - -ここにマイグレーションの例があります。 :: - - table('products'); - $table->addColumn('name', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]); - $table->addColumn('description', 'text', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('created', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('modified', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->create(); - } - } - -マイグレーションは、データベースに ``products`` という名前のテーブルを追加します。 -以下のカラムが定義します。 - -- ``id`` カラムの型は、主キーの ``integer`` -- ``name`` カラムの型は ``string`` -- ``description`` カラムの型は ``text`` -- ``created`` カラムの型は ``datetime`` -- ``modified`` カラムの型は ``datetime`` - -.. tip:: - - 主キーのカラム名 ``id`` は、 **暗黙のうちに** 追加されます。 - -.. note:: - - このファイルは変更を **適用後** にデータベースがどのようになるかを記述していることに - 注意してください。この時点でデータベースに ``products`` テーブルは存在せず、 - ``products`` テーブルを作って項目を追加することができるのと同様に、マイグレーションを - ``rollback`` すればテーブルが消えてしまいます。 - -マイグレーションファイルを **config/Migrations** フォルダーに作成したら、下記の -``migrations`` コマンドを実行することでデータベースにテーブルを作成することがでます。 : - -.. code-block:: bash - - bin/cake migrations migrate - -以下の ``migrations`` コマンドは、 ``rollback`` を実行するとあなたのデータベースから -テーブルが削除されます。 - -.. code-block:: bash - - bin/cake migrations rollback - -マイグレーションファイルの作成 -============================== - -マイグレーションファイルは、あなたのアプリケーションの **config/Migration** -ディレクトリーに配置します。マイグレーションファイルの名前には、先頭に -**YYYYMMDDHHMMSS_MigrationName.php** というように作成した日付を付けます。 -以下がマイグレーションファイルの例です。 - -* 20160121163850_CreateProducts.php -* 20160210133047_AddRatingToProducts.php - -マイグレーションファイルを作成する最も簡単な方法は ``bake`` CLI -コマンドを使用することです。 - -マイグレーションファイルに記述可能なメソッドの一覧については、オフィシャルの -`Phinx ドキュメント `_ -をご覧ください。 - -.. note:: - - ``bake`` オプションを使用する場合、もし望むなら実行する前にマイグレーションを修正できます。 - -シンタックス ------------- - -以下の ``bake`` コマンドは、 ``products`` テーブルを追加するためのマイグレーションファイルを -作成します。 : - -.. code-block:: bash - - bin/cake bake migration CreateProducts name:string description:text created modified - -あなたのデータベースにテーブルの作成、カラムの追加などをするために ``bake`` を使用する場合、 -一般に以下の2点を指定します。 - -* あなたが生成するマイグレーションの名前 (例えば、 ``CreateProducts``) -* マイグレーションで追加や削除を行うテーブルのカラム - (例えば、 ``name:string description:text created modified``) - -規約のために、すべてのスキーマの変更がこれらのシェルコマンドで動作するわけではありません。 - -さらに、実行内容を完全に制御したいのであれば、空のマイグレーションファイルを -作る事ができます。 - -.. code-block:: bash - - bin/cake migrations create MyCustomMigration - -マイグレーションファイル名 -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -マイグレーション名は下記のパターンに従うことができます。 - -* (``/^(Create)(.*)/``) 指定したテーブルを作成します。 -* (``/^(Drop)(.*)/``) 指定したテーブルを削除します。フィールドの指定は無視されます。 -* (``/^(Add).*(?:To)(.*)/``) 指定したテーブルにカラム追加します。 -* (``/^(Remove).*(?:From)(.*)/``) 指定のテーブルのカラムを削除します。 -* (``/^(Alter)(.*)/``) 指定したテーブルを変更します。 CreateTable と AddField の別名。 -* (``/^(Alter).*(?:On)(.*)/``) 指定されたテーブルのフィールドを変更します。 - -マイグレーションの名前に ``アンダースコアー_形式`` を使用できます。例: create_products - -.. versionadded:: cakephp/migrations 1.5.2 - - マイグレーションファイル名のキャメルケースへの変換は `migrations プラグイン - `_ の v1.5.2 に含まれます。 - このプラグインのバージョンは、 CakePHP 3.1 以上のリリースで利用できます。 - このプラグインのバージョン以前では、マイグレーション名はアンダースコアー形式です。 - 例: 20160121164955_create_products.php - -.. warning:: - - マイグレーション名は、マイグレーションのクラス名として使われます。そして、 - クラス名はユニークでない場合、他のマイグレーションと衝突するかもしれません。この場合、後日、 - 名前を手動で上書きするか、単純にあなたが指定した名前に変更する必要があるかもしれません。 - -カラムの定義 -~~~~~~~~~~~~ - -コマンドラインでカラムを使用する場合には、次のようなパターンに従っている事を -覚えておくと便利です。 :: - - fieldName:fieldType?[length]:indexType:indexName - -例えば、以下はメールアドレスのカラムを指定する方法です。 - -* ``email:string?`` -* ``email:string:unique`` -* ``email:string?[50]`` -* ``email:string:unique:EMAIL_INDEX`` -* ``email:string[120]:unique:EMAIL_INDEX`` - -fieldType の後のクエスチョンマークは、ヌルを許可するカラムを作成します。 - -``fieldType`` のための ``length`` パラメーターは任意です。カッコの中に記述します。 - -フィールド名が ``created`` と ``modified`` 、それに ``_at`` サフィックス付きの -任意のフィールドなら、自動的に ``datetime`` 型が設定されます。 - -``Phinx`` で一般的に利用可能なフィールドの型は次の通り: - -* string -* text -* integer -* biginteger -* float -* decimal -* datetime -* timestamp -* time -* date -* binary -* boolean -* uuid - -未確定で無効な値のままのフィールド型を選ぶためのいくつかの発見的手法があります。 -デフォルトのフィールド型は ``string`` です。 - -* id: integer -* created, modified, updated: datetime - -テーブルの作成 --------------- - -テーブルを作成するために ``bake`` が使えます。 : - -.. code-block:: bash - - bin/cake bake migration CreateProducts name:string description:text created modified - -上記のコマンドラインは、よく似たマイグレーションファイルを生成します。 :: - - table('products'); - $table->addColumn('name', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]); - $table->addColumn('description', 'text', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('created', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('modified', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->create(); - } - } - -既存のテーブルにカラムを追加 ----------------------------- - -もしコマンドラインのマイグレーション名が "AddXXXToYYY" といった -書式で、その後にカラム名と型が続けば、カラムの追加を行うコードを含んだ -マイグレーションファイルが生成されます。 : - -.. code-block:: bash - - bin/cake bake migration AddPriceToProducts price:decimal - -コマンドラインを実行すると下記のようなファイルが生成されます。 :: - - table('products'); - $table->addColumn('price', 'decimal') - ->update(); - } - } - -テーブルにインデックスとしてカラムを追加 ----------------------------------------- - -カラムにインデックスを追加することも可能です。 : - -.. code-block:: bash - - bin/cake bake migration AddNameIndexToProducts name:string:index - -このようなファイルが生成されます。 :: - - table('products'); - $table->addColumn('name', 'string') - ->addIndex(['name']) - ->update(); - } - } - -フィールド長を指定 ------------------- - -.. versionadded:: cakephp/migrations 1.4 - -もし、フィールド長を指定する必要がある場合、フィールドタイプにカギ括弧の中で指定できます。例: - -.. code-block:: bash - - bin/cake bake migration AddFullDescriptionToProducts full_description:string[60] - -上記のコマンドラインを実行すると生成されます。 :: - - table('products'); - $table->addColumn('full_description', 'string', [ - 'default' => null, - 'limit' => 60, - 'null' => false, - ]) - ->update(); - } - } - -長さが未指定の場合、いくつかのカラム型の長さは初期値が設定されます。 - -* string: 255 -* integer: 11 -* biginteger: 20 - -テーブルから列を変更する ------------------------------------ - -同様に、移行名が「AlterXXXOnYYY」の形式の場合、コマンドラインを使用して、列を変更する移行を生成できます。 - -.. code-block:: bash - - bin/cake bake migration AlterPriceOnProducts name:float - -生成されます:: - - table('products'); - $table->changeColumn('name', 'float'); - $table->update(); - } - } - -テーブルからカラムを削除 ------------------------- - -もしマイグレーション名が "RemoveXXXFromYYY" であるなら、同様にコマンドラインを使用して、 -カラム削除のマイグレーションファイルを生成することができます。 : - -.. code-block:: bash - - bin/cake bake migration RemovePriceFromProducts price - -このようなファイルが生成されます。 :: - - table('products'); - $table->removeColumn('price') - ->save(); - } - } - -.. note:: - - `removeColumn` は不可逆ですので、 `up` メソッドの中で呼び出してください。 - それに対する `addColumn` の呼び出しは、 `down` メソッドに追加してください。 - -既存のデータベースからマイグレーションファイルを作成する --------------------------------------------------------- - -もしあなたが既存のデータベースで、マイグレーションの使用を始めたい場合や、 -あなたのアプリケーションのデータベースで初期状態のスキーマのバージョン管理を -行いたい場合、 ``migration_snapshot`` コマンドを実行します。 : - -.. code-block:: bash - - bin/cake bake migration_snapshot Initial - -これはデータベース内のすべてのテーブルの create 文を含んだ **YYYYMMDDHHMMSS_Initial.php** -と呼ばれるマイグレーションファイルを生成します。 - -デフォルトで、スナップショットは、 ``default`` 接続設定で定義されたデータベースに -接続することによって作成されます。 -もし、異なるデータベースからスナップショットを bake する必要があるなら、 -``--connection`` オプションが使用できます。 : - -.. code-block:: bash - - bin/cake bake migration_snapshot Initial --connection my_other_connection - -``--require-table`` フラグを使用することによって対応するモデルクラスを定義したテーブルだけを -含まれることを確認することができます。 : - -.. code-block:: bash - - bin/cake bake migration_snapshot Initial --require-table - -``--require-table`` フラグを使用した時、シェルは、あなたのアプリケーションを通して -``Table`` クラスを見つけて、スナップショットのモデルテーブルのみ追加します。 - -プラグインのためのスナップショットを bake したい場合、同じロジックが暗黙的に適用されます。 -そうするために、 ``--plugin`` オプションを使用する必要があります。 : - -.. code-block:: bash - - bin/cake bake migration_snapshot Initial --plugin MyPlugin - -定義された ``Table`` オブジェクトモデルを持つテーブルだけプラグインのスナップショットに -追加されます。 - -.. note:: - - プラグインのためのスナップショットを bake した時、マイグレーションファイルは、 - あなたのプラグインの **config/Migrations** ディレクトリーに作成されます。 - -スナップショットを bake した時、phinx のログテーブルに自動的に追加されることに注意してください。 - -2つのデータベース間の状態の差分を生成する -============================================= - -.. versionadded:: cakephp/migrations 1.6.0 - -``migration_diff`` の bake テンプレートを使用して2つのデータベースの状態の -すべての差分をまとめたマイグレーションファイルを生成することができます。 -そのためには、以下のコマンドを使用します。 : - -.. code-block:: bash - - bin/cake bake migration_diff NameOfTheMigrations - -現在のデータベースの状態からの比較のポイントを保持するために、migrations シェルは、 -``migrate`` もしくは ``rollback`` が呼ばれた後に "dump" ファイルを生成します。 -ダンプファイルは、取得した時点でのあなたのデータベースの全スキーマの状態を含むファイルです。 - -一度ダンプファイルが生成されると、あなたのデータベース管理システムに直接行ったすべての変更は、 -``bake migration_diff`` コマンドが呼ばれた時に生成されたマイグレーションファイルに追加されます。 - -デフォルトでは、 ``default`` 接続設定に定義されたデータベースに接続することによって -差分が作成されます。もし、あなたが異なるデータソースから差分を bake する必要がある場合、 -``--connection`` オプションを使用できます。 : - -.. code-block:: bash - - bin/cake bake migration_diff NameOfTheMigrations --connection my_other_connection - -もし、すでにマイグレーションの履歴を持つアプリケーション上で diff 機能を使用したい場合、 -マニュアルで比較に使用するダンプファイルを作成する必要があります。 : - -.. code-block:: bash - - bin/cake migrations dump - -データベースの状態は、あなたがダンプファイルを作成する前にマイグレーションを全て実行した状態と -同じでなければなりません。一度ダンプファイルが生成されると、あなたのデータベースの変更を始めて、 -都合の良い時に ``bake migration_diff`` コマンドを使用することができます。 - -.. note:: - - migrations シェルは、カラム名の変更は検知できません。 - -コマンド -======== - -``migrate`` : マイグレーションを適用する ----------------------------------------- - -マイグレーションファイルを生成したり記述したら、以下のコマンドを実行して -変更をデータベースに適用しましょう。 : - -.. code-block:: bash - - # マイグレーションをすべて実行 - bin/cake migrations migrate - - # 特定のバージョンに移行するためには、 ``--target`` オプション - # (省略形は ``-t`` )を使用します。 - # これはマイグレーションファイル名の前に付加されるタイムスタンプに対応しています。 - bin/cake migrations migrate -t 20150103081132 - - # デフォルトで、マイグレーションファイルは、 **config/Migrations** ディレクトリーに - # あります。 ``--source`` オプション (省略形は ``-s``) を使用することで、 - # ディレクトリーを指定できます。 - # 次の例は、 **config/Alternate** ディレクトリー内でマイグレーションを実行します。 - bin/cake migrations migrate -s Alternate - - # ``--connection`` オプション (省略形は ``-c``) を使用することで - # ``default`` とは異なる接続でマイグレーションを実行できます。 - bin/cake migrations migrate -c my_custom_connection - - # マイグレーションは、プラグインのためにも実行できます。 ``--plugin`` オプション - # (省略形は ``-p``) を使用します。 - bin/cake migrations migrate -p MyAwesomePlugin - -``rollback`` : マイグレーションを戻す -------------------------------------- - -ロールバックコマンドは、このプラグインを実行する前の状態に戻すために使われます。 -これは ``migrate`` コマンドの逆向きの動作をします。 : - -.. code-block:: bash - - # あなたは ``rollback`` コマンドを使って以前のマイグレーション状態に戻すことができます。 - bin/cake migrations rollback - - # また、特定のバージョンに戻すために、マイグレーションバージョン番号を引き渡すこともできます。 - bin/cake migrations rollback -t 20150103081132 - -``migrate`` コマンドのように ``--source`` 、 ``--connection`` そして ``--plugin`` -オプションが使用できます。 - -``status`` : マイグレーションのステータス ------------------------------------------ - -Status コマンドは、現在の状況とすべてのマイグレーションのリストを出力します。 -あなたはマイグレーションが実行されたかを判断するために、このコマンドを使用することができます。 : - -.. code-block:: bash - - bin/cake migrations status - -``--format`` (省略形は ``-f``) オプションを使用することで -JSON 形式の文字列として結果を出力できます。 : - -.. code-block:: bash - - bin/cake migrations status --format json - -``migrate`` コマンドのように ``--source`` 、 ``--connection`` そして ``--plugin`` -オプションが使用できます。 - -``mark_migrated`` : マイグレーション済みとしてマーキングする ------------------------------------------------------------- - -.. versionadded:: 1.4.0 - -時には、実際にはマイグレーションを実行せずにマークだけすることが便利な事もあります。 -これを実行するためには、 ``mark_migrated`` コマンドを使用します。 -コマンドは、他のコマンドとしてシームレスに動作します。 - -このコマンドを使用して、すべてのマイグレーションをマイグレーション済みとして -マークすることができます。 : - -.. code-block:: bash - - bin/cake migrations mark_migrated - -また、 ``--target`` オプションを使用して、指定したバージョンに対して、 -すべてマイグレーション済みとしてマークすることができます。 : - -.. code-block:: bash - - bin/cake migrations mark_migrated --target=20151016204000 - -もし、指定したマイグレーションを処理中にマーク済みにしたくない場合、 -``--exclude`` フラグをつけて使用することができます。 : - -.. code-block:: bash - - bin/cake migrations mark_migrated --target=20151016204000 --exclude - -最後に、指定したマイグレーションだけをマイグレーション済みとしてマークしたい場合、 -``--only`` フラグを使用できます。 : - -.. code-block:: bash - - bin/cake migrations mark_migrated --target=20151016204000 --only - -``migrate`` コマンドのように ``--source`` 、 ``--connection`` そして ``--plugin`` -オプションが使用できます。 - -.. note:: - - あなたが ``cake bake migration_snapshot`` コマンドでスナップショットを作成したとき、 - 自動的にマイグレーション済みとしてマーキングされてマイグレーションが作成されます。 - -.. deprecated:: 1.4.0 - - 以下のコマンドの使用方法は非推奨になりました。もし、あなたが 1.4.0 より前のバージョンの - プラグインの場合のみに使用してください。 - -このコマンドは、引数としてマイグレーションバージョン番号を想定しています。 : - -.. code-block:: bash - - bin/cake migrations mark_migrated 20150420082532 - -もし、すべてのマイグレーションをマイグレーション済みとしてマークしたい場合、 -特別な値 ``all`` を使用できます。もし使用した場合、すべての見つかったマイグレーションを -マイグレーション済みとしてマークします。 : - -.. code-block:: bash - - bin/cake migrations mark_migrated all - -``seed`` : データベースの初期データ投入 ----------------------------------------- - -1.5.5 より、データベースの初期データ投入のために ``migrations`` シェルが使用できます。 -これは、 `Phinx ライブラリーの seed 機能 `_ -を利用しています。デフォルトで、seed ファイルは、あなたのアプリケーションの ``config/Seeds`` -ディレクトリーの中に置かれます。 `seed ファイル作成のための Phinx の命令 -`_ -を確認してください。 - -マイグレーションに関して、 seed ファイルのための ``bake`` インターフェースが提供されます。 : - -.. code-block:: bash - - # これは、あなたのアプリケーションの config/Seeds ディレクトリー内に ArticlesSeed.php を作成します。 - # デフォルトでは、変換対象の seed は、 "tableized" バージョンの seed ファイル名です。 - bin/cake bake seed Articles - - # ``--table`` オプションを使用することで seed ファイルに変換するテーブル名を指定します。 - bin/cake bake seed Articles --table my_articles_table - - # bake するプラグインを指定できます。 - bin/cake bake seed Articles --plugin PluginName - - # シーダーの生成時に別の接続を指定できます。 - bin/cake bake seed Articles --connection connection - -.. versionadded:: cakephp/migrations 1.6.4 - - オプションの ``--data``, ``--limit`` そして ``--fields`` は、 - データベースからデータをエクスポートするために追加されました。 - -1.6.4 から、 ``bake seed`` コマンドは、 ``--data`` フラグを使用することによって、 -データベースからエクスポートされたデータを元に seed ファイルを作成することができます。 : - -.. code-block:: bash - - bin/cake bake seed --data Articles - -デフォルトでは、テーブル内にある行を全てエクスポートします。 ``--limit`` オプションを -使用することによって、エクスポートされる行の数を制限できます。 : - -.. code-block:: bash - - # 10 行のみエクスポート - bin/cake bake seed --data --limit 10 Articles - -もし、seed ファイルの中にテーブルから選択したフィールドのみを含めたい場合、 -``--fields`` オプションが使用できます。そのオプションは、 -フィールドのリストをカンマ区切りの値の文字列として含めます。 : - -.. code-block:: bash - - # `id`, `title` そして `excerpt` フィールドのみをエクスポート - bin/cake bake seed --data --fields id,title,excerpt Articles - -.. tip:: - - もちろん、同じコマンド呼び出し中に ``--limit`` と ``--fields`` - オプションの両方が利用できます。 - -データベースの初期データ投入のために、 ``seed`` サブコマンドが使用できます。 : - -.. code-block:: bash - - # パラメーターなしの seed サブコマンドは、対象のディレクトリーのアルファベット順で、 - # すべての利用可能なシーダーを実行します。 - bin/cake migrations seed - - # `--seed` オプションを使用して実行するための一つだけシーダーを指定できます。 - bin/cake migrations seed --seed ArticlesSeed - - # 別のディレクトリーでシーダーを実行できます。 - bin/cake migrations seed --source AlternativeSeeds - - # プラグインのシーダーを実行できます - bin/cake migrations seed --plugin PluginName - - # 指定したコネクションでシーダーを実行できます - bin/cake migrations seed --connection connection - -マイグレーションとは対照的にシーダーは追跡されないことに注意してください。 -それは、同じシーダーは、複数回適用することができることを意味します。 - -シーダーから別のシーダーの呼び出し -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. versionadded:: cakephp/migrations 1.6.2 - -たいてい初期データ投入時は、データの挿入する順番は、規約違反しないように遵守しなければなりません。 -デフォルトでは、アルファベット順でシーダーが実行されますが、独自にシーダーの実行順を定義するために -``\Migrations\AbstractSeed::call()`` メソッドが利用できます。 :: - - use Migrations\AbstractSeed; - - class DatabaseSeed extends AbstractSeed - { - public function run(): void - { - $this->call('AnotherSeed'); - $this->call('YetAnotherSeed'); - - // プラグインからシーダーを呼ぶためにプラグインドット記法が使えます - $this->call('PluginName.FromPluginSeed'); - } - } - -.. note:: - - もし、 ``call()`` メソッドを使いたい場合、Migrations プラグインの ``AbstractSeed`` - クラスを継承していることを確認してください。このクラスは、リリース 1.6.2 で追加されました。 - -``dump`` : 差分を bake する機能のためのダンプファイルの生成 -------------------------------------------------------------- - -dump コマンドは、 ``migration_diff`` の bake テンプレートで使用するファイルを作成します。 : - -.. code-block:: bash - - bin/cake migrations dump - -各生成されたダンプファイルは、生成元の接続固有のものです(そして、そのようにサフィックスされます)。 -これは、アプリケーションが、異なるデータベースベンダーの複数のデータベースを扱う場合、 -``bake migration_diff`` コマンドで正しく差分を算出することができます。 - -ダンプファイルは、マイグレーションファイルと同じディレクトリーに作成されます。 - -``migrate`` コマンドのように ``--source`` 、 ``--connection`` そして ``--plugin`` -オプションが使用できます。 - -プラグイン内のマイグレーションファイルを使う -============================================ - -プラグインはマイグレーションファイルも提供することができます。 -これはプラグインの移植性とインストールの容易さを高め、配布しやすくなるように意図されています。 -Migrations プラグインの全てのコマンドは、プラグイン関連のマイグレーションを行うための -``--plugin`` か ``-p`` オプションをサポートしています。 : - -.. code-block:: bash - - bin/cake migrations status -p PluginName - - bin/cake migrations migrate -p PluginName - -非シェルの環境でマイグレーションを実行する -========================================== - -.. versionadded:: cakephp/migrations 1.2.0 - -migrations プラグインのバージョン 1.2 から、非シェル環境でも app から直接 -``Migrations`` クラスを使ってマイグレーションを実行できるようになりました。 -これは CMS のプラグインインストーラーを作る時などに便利です。 -``Migrations`` クラスを使用すると、マイグレーションシェルから下記のコマンドを -実行することができます。: - -* migrate -* rollback -* markMigrated -* status -* seed - -それぞれのコマンドは ``Migrations`` クラスのメソッドとして実装されています。 - -使い方は以下の通りです。 :: - - use Migrations\Migrations; - - $migrations = new Migrations(); - - // 全てのマイグレーションバージョンとそのステータスの配列を返します。 - $status = $migrations->status(); - - // 成功した場合、 true を返し、エラーが発生した場合、例外が投げられます。 - $migrate = $migrations->migrate(); - - // 成功した場合、 true を返し、エラーが発生した場合、例外が投げられます。 - $rollback = $migrations->rollback(); - - // 成功した場合、 true を返し、エラーが発生した場合、例外が投げられます。 - $markMigrated = $migrations->markMigrated(20150804222900); - - // 成功した場合、 true を返し、エラーが発生した場合、例外が投げられます。 - $seeded = $migrations->seed(); - -メソッドはコマンドラインのオプションと同じパラメーター配列を受け取ります。 :: - - use Migrations\Migrations; - - $migrations = new Migrations(); - - // 全てのマイグレーションバージョンとそのステータスの配列を返す - $status = $migrations->status(['connection' => 'custom', 'source' => 'MyMigrationsFolder']); - -あなたはシェルコマンドのように任意のオプションを引き渡すことができます。 -唯一の例外は ``markMigrated`` コマンドで、第1引数にはマイグレーション済みとして -マーキングしたいマイグレーションバージョン番号を渡し、第2引数にパラメーターの配列を -渡します。 - -必要に応じて、クラスのコンストラクターでこれらのパラメーターを引き渡すことができます。 -それはデフォルトとして使用され、それぞれのメソッド呼び出しの時に引き渡されることを -防止します。 :: - - use Migrations\Migrations; - - $migrations = new Migrations(['connection' => 'custom', 'source' => 'MyMigrationsFolder']); - - // 以下のすべての呼び出しは、マイグレーションクラスのコンストラクターに渡されたパラメーターを使用して行われます - $status = $migrations->status(); - $migrate = $migrations->migrate(); - -個別の呼び出しでデフォルトのパラメーターを上書きしたい場合は、メソッド呼び出し時に引き渡します。 :: - - use Migrations\Migrations; - - $migrations = new Migrations(['connection' => 'custom', 'source' => 'MyMigrationsFolder']); - - // この呼び出しでは "custom" コネクションを使用します。 - $status = $migrations->status(); - // こちらでは "default" コネクションを使用します。 - $migrate = $migrations->migrate(['connection' => 'default']); - -小技と裏技 -=============== - -主キーをカスタマイズする ------------------------- - -あなたがデータベースに新しいテーブルを作成する時、 ``id`` を主キーとして -自動生成したくない場合、 ``table()`` メソッドの第2引数を使うことができます。 :: - - table('products', ['id' => false, 'primary_key' => ['id']]); - $table - ->addColumn('id', 'uuid') - ->addColumn('name', 'string') - ->addColumn('description', 'text') - ->create(); - } - } - -上記の例では、 ``CHAR(36)`` の ``id`` というカラムを主キーとして作成します。 - -.. note:: - - 独自の主キーをコマンドラインで指定した時、id フィールドの中の主キーとして注意してください。 - そうしなければ、id フィールドが重複してエラーになります。例: - - .. code-block:: bash - - bin/cake bake migration CreateProducts id:uuid:primary name:string description:text created modified - -さらに、Migrations 1.3 以降では 主キーに対処するための新しい方法が導入されました。 -これを行うには、あなたのマイグレーションクラスは新しい ``Migrations\AbstractMigration`` -クラスを継承する必要があります。 -あなたは Migration クラスの ``autoId`` プロパティーに ``false`` を設定することで、 -自動的な ``id`` カラムの生成をオフにすることができます。 -あなたは手動で主キーカラムを作成し、テーブル宣言に追加する必要があります。 :: - - table('products'); - $table - ->addColumn('id', 'integer', [ - 'autoIncrement' => true, - 'limit' => 11 - ]) - ->addPrimaryKey('id') - ->addColumn('name', 'string') - ->addColumn('description', 'text') - ->create(); - } - } - -主キーを扱うこれまでの方法と比較すると、この方法は、unsigned や not や limit や comment など -さらに多くの主キーの定義を操作することができるようになっています。 - -Bake で生成されたマイグレーションファイルとスナップショットは、この新しい方法を -必要に応じて使用します。 - -.. warning:: - - 主キーの操作ができるのは、テーブル作成時のみです。これはプラグインがサポートしている - いくつかのデータベースサーバーの制限によるものです。 - -照合順序 --------- - -もしデータベースのデフォルトとは別の照合順序を持つテーブルを作成する必要がある場合は、 -``table()`` メソッドのオプションとして定義することができます。:: - - table('categories', [ - 'collation' => 'latin1_german1_ci' - ]) - ->addColumn('title', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]) - ->create(); - } - } - -ですが、これはテーブル作成時にしかできず、既存のテーブルに対してカラムを追加する時に -テーブルやデータベースと異なる照合順序を指定する方法がないことに注意してください。 -ただ ``MySQL`` と ``SqlServer`` だけはこの設定キーをサポートしています。 - -カラム名の更新と Table オブジェクトの使用 ------------------------------------------ - -カラムのリネームや移動とともに、あなたのデータベースから値を操作するために -CakePHP ORM Table オブジェクトを使用している場合、 ``update()`` を呼んだ後に Table -オブジェクトの新しいインスタンスを作成できることを確かめてください。 -インスタンス上の Table オブジェクトに反映し保存されたスキーマをリフレッシュするために -Table オブジェクトのレジストリーは、 ``update()`` が呼ばれた後にクリアされます。 - -マイグレーションとデプロイメント --------------------------------- - -もし、アプリケーションをデプロイする時にプラグインを使用する場合、 -テーブルのカラムメタデータを更新するように、必ず ORM キャッシュをクリアしてください。 -そうしなければ、それらの新しいカラムの操作を実行する時に、カラムが存在しないエラーになります。 -CakePHP コアは、この操作を行うために使用できる `スキーマキャッシュシェル -`__ を含みます。 : - -.. code-block:: bash - - // 3.6.0 より前の場合、orm_cache を使用 - bin/cake schema_cache clear - -このシェルについてもっと知りたい場合、クックブックの -`スキーマキャッシュシェル `__ -セクションをご覧ください。 - -テーブルのリネーム ------------------- - -プラグインは、 ``rename()`` メソッドを使用することでテーブルのリネームができます。 -あなたのマイグレーションファイルの中で、以下のように記述できます。 :: - - public function up(): void - { - $this->table('old_table_name') - ->rename('new_table_name') - ->save(); - } - -``schema.lock`` ファイル生成のスキップ --------------------------------------------- - -.. versionadded:: cakephp/migrations 1.6.5 - -diff 機能を動作させるために、 **.lock** ファイルは、migrate、rollback または -スナップショットの bake の度に生成され、指定された時点でのデータベーススキーマの状態を追跡します。 -例えば本番環境上にデプロイするときなど、前述のコマンドに ``--no-lock`` -オプションを使用することによって、このファイルの生成をスキップすることができます。 : - -.. code-block:: bash - - bin/cake migrations migrate --no-lock - - bin/cake migrations rollback --no-lock - - bin/cake bake migration_snapshot MyMigration --no-lock - diff --git a/docs/pt/conf.py b/docs/pt/conf.py deleted file mode 100644 index 9e22cb017..000000000 --- a/docs/pt/conf.py +++ /dev/null @@ -1,9 +0,0 @@ -import sys, os - -# Append the top level directory of the docs, so we can import from the config dir. -sys.path.insert(0, os.path.abspath('..')) - -# Pull in all the configuration options defined in the global config file.. -from config.all import * - -language = 'pt' diff --git a/docs/pt/contents.rst b/docs/pt/contents.rst deleted file mode 100644 index 1459b1f1b..000000000 --- a/docs/pt/contents.rst +++ /dev/null @@ -1,5 +0,0 @@ -.. toctree:: - :maxdepth: 2 - :caption: CakePHP Migrations - - /index diff --git a/docs/pt/index.rst b/docs/pt/index.rst deleted file mode 100644 index be48eb7ac..000000000 --- a/docs/pt/index.rst +++ /dev/null @@ -1,943 +0,0 @@ -Migrations -########## - -Migrations é um plugin suportado pela equipe oficial do CakePHP que ajuda você a -fazer mudanças no **schema** do banco de dados utilizando arquivos PHP, -que podem ser versionados utilizando um sistema de controle de versão. - -Ele permite que você atualize suas tabelas ao longo do tempo. Ao invés de -escrever modificações de **schema** via SQL, este plugin permite que você -utilize um conjunto intuitivo de métodos para fazer mudanças no seu banco de -dados. - -Esse plugin é um **wrapper** para a biblioteca `Phinx `_. - -Instalação -========== - -Por padrão o plugin é instalado junto com o esqueleto da aplicação. -Se você o removeu e quer reinstalá-lo, execute o comando a seguir a partir do -diretório **ROOT** da sua aplicação -(onde o arquivo composer.json está localizado): - -.. code-block:: bash - - $ php composer.phar require cakephp/migrations "@stable" - - # Or if composer is installed globally - - $ composer require cakephp/migrations "@stable" - -Para usar o plugin você precisa carregá-lo no arquivo **config/bootstrap.php** -da sua aplicação. Você pode usar o -`shell de plugins do CakePHP -`__ para carregar e descarregar -plugins do seu arquivo **config/bootstrap.php**:: - - $ bin/cake plugin load Migrations - -Ou você pode carregar o plugin editando seu arquivo **config/bootstrap.php** e -adicionando a linha:: - - Plugin::load('Migrations'); - -Adicionalmente, você precisará configurar o banco de dados padrão da sua -aplicação, no arquivo **config/app.php** como explicado na seção -`Configuração de banco de dados `__. - -Visão Geral -=========== - -Uma migração é basicamente um arquivo PHP que descreve as mudanças a -serem feitas no banco de dados. Um arquivo de migração pode criar ou excluir -tabelas, adicionar ou remover colunas, criar índices e até mesmo inserir -dados em seu banco de dados. - -Aqui segue um exemplo de migração:: - - table('products'); - $table->addColumn('name', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]); - $table->addColumn('description', 'text', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('created', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('modified', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->create(); - } - } - -Essa migração irá adicionar uma tabela chamada ``products`` ao banco de dados -com as seguintes colunas: - -- ``id`` coluna do tipo ``integer`` como chave primária -- ``name`` coluna do tipo ``string`` -- ``description`` coluna do tipo ``text`` -- ``created`` coluna do tipo ``datetime`` -- ``modified`` coluna do tipo ``datetime`` - -.. tip:: - - A coluna de chave primária ``id`` será adicionada **implicitamente**. - -.. note:: - - Note que este arquivo descreve como o banco de dados deve ser **após** a - aplicação da migração. Neste ponto, a tabela ``products``ainda não existe - no banco de dados, nós apenas criamos um arquivo que é capaz de criar a - tabela ``products`` com seus devidos campos ou excluir a tabela caso uma - operação rollback seja executada. - -Com o arquivo criado na pasta **config/MIgrations**, você será capaz de executar -o comando abaixo para executar as migrações no seu banco de dados:: - - bin/cake migrations migrate - -O comando seguinte irá executar um **rollback** na migração e irá excluir a -tabela recém criada:: - - bin/cake migrations rollback - -Criando migrations -================== - -Arquivos de migração são armazeados no diretório **config/Migrations** da -sua aplicação. O nome dos arquivos de migração têm como prefixo a data -em que foram criados, no formato **YYYYMMDDHHMMSS_MigrationName.php**. Aqui -estão exemplos de arquivos de migração: - -* 20160121163850_CreateProducts.php -* 20160210133047_AddRatingToProducts.php - -A maneira mais fácil de criar um arquivo de migrações é usando o -``bin/cake bake migration`` a linha de comando. - -Por favor, leia a `documentação do Phinx ` -a fim de conhecer a lista completa dos métodos que você pode usar para escrever -os arquivos de migração. - -.. note:: - - Ao gerar as migrações através do ``bake`` você ainda pode alterá-las antes - da sua execução, caso seja necessário. - -Sintaxe -------- - -A sintaxe do ``bake`` para a geração de migrações segue o formato abaixo:: - - $ bin/cake bake migration CreateProducts name:string description:text created modified - -Quando utilizar o ``bake`` para criar as migrações, você normalmente precisará -informar os seguintes dados:: - - * o nome da migração que você irá gerar (``CreateProducts`` por exemplo) - * as colunas da tabela que serão adicionadas ou removidas na migração - (``name:string description:text created modified`` no nosso caso) - -Devido às convenções, nem todas as alterações de schema podem ser realizadas -através destes comandos. - -Além disso, você pode criar um arquivo de migração vazio caso deseje ter um -controle total do que precisa ser executado. Para isto, apenas omita a definição -das colunas:: - - $ bin/cake migrations create MyCustomMigration - -Nomenclatura de migrations -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -A nomenclatura das migrações pode seguir qualquer um dos padrões apresentados a -seguir: - -* (``/^(Create)(.*)/``) Cria a tabela especificada. -* (``/^(Drop)(.*)/``) Exclui a tabela especificada. - Ignora campos especificados nos argumentos -* (``/^(Add).*(?:To)(.*)/``) Adiciona campos a - tabela especificada -* (``/^(Remove).*(?:From)(.*)/``) Remove campos de uma - tabela específica -* (``/^(Alter)(.*)/``) Altera a tabela especificada. Um apelido para - um CreateTable seguido de um AlterTable -* (``/^(Alter).*(?:On)(.*)/``) Alterar os campos da tabela especificada - -Você também pode usar ``underscore_form`` como nome das suas **migrations**. -Ex.: ``create_products``. - -.. versionadded:: cakephp/migrations 1.5.2 - - A partir da versão 1.5.2 do `plugin migrations `_, - o nome dos arquivos de migrações são colocados automaticamente no padrão - **camel case**. - Esta versão do plugin está disponível apenas a partir da versão 3.1 do - CakePHP. - Antes disto, o padrão de nomes do plugin migrations utilizava a nomenclatura - baseada em **underlines**, ex.: ``20160121164955_create_products.php``. - -.. warning:: - - O nome das migrações são usados como nomes de classe, e podem colidir com - outras migrações se o nome das classes não forem únicos. Neste caso, pode - ser necessário sobreescrever manualmente os nomes mais tarde ou simplesmente - mudar os nomes que você está especificando. - -Definição de colunas -~~~~~~~~~~~~~~~~~~~~ - -Quando utilizar colunas na linha de comando, pode ser útil lembrar que eles seguem o -seguinte padrão:: - - fieldName:fieldType[length]:indexType:indexName - -Por exemplo, veja formas válidas de especificar um campo de e-mail: - -* ``email:string:unique`` -* ``email:string:unique:EMAIL_INDEX`` -* ``email:string[120]:unique:EMAIL_INDEX`` - -O parâmetro ``length`` para o ``fieldType`` é opcional e deve sempre ser -escrito entre colchetes - -Os campos ``created`` e ``modified`` serão automaticamente definidos -como ``datetime``. - -Os tipos de campos são genericamente disponibilizados pela biblioteca ``Phinx``. -Eles podem ser: - -* string -* text -* integer -* biginteger -* float -* decimal -* datetime -* timestamp -* time -* date -* binary -* boolean -* uuid - -Há algumas heurísticas para a escolha de tipos de campos que não são especificados -ou são definidos com valor inválido. O tipo de campo padrão é ``string``; - -* id: integer -* created, modified, updated: datetime - -Criando uma tabela ------------------- - -Você pode utilizar o ``bake`` para criar uma tabela:: - - $ bin/cake bake migration CreateProducts name:string description:text created modified - -A linha de comando acima irá gerar um arquivo de migração parecido com este:: - - table('products'); - $table->addColumn('name', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]); - $table->addColumn('description', 'text', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('created', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('modified', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->create(); - } - } - -Adicionando colunas a uma tabela existente ------------------------------------------- - -Se o nome da migração na linha de comando estiver na forma "AddXXXToYYY" e -for seguido por uma lista de nomes de colunas e tipos, então o arquivo de -migração com o código para criar as colunas será gerado:: - - $ bin/cake bake migration AddPriceToProducts price:decimal - -A linha de comando acima irá gerar um arquivo com o seguinte conteúdo:: - - table('products'); - $table->addColumn('price', 'decimal') - ->update(); - } - } - -Adicionando uma coluna como indice a uma tabela ------------------------------------------------ - -Também é possível adicionar índices a colunas:: - - $ bin/cake bake migration AddNameIndexToProducts name:string:index - -irá gerar:: - - table('products'); - $table->addColumn('name', 'string') - ->addIndex(['name']) - ->update(); - } - } - -Especificando o tamanho do campo --------------------------------- - -.. versionadded:: cakephp/migrations 1.4 - -Se você precisar especificar o tamanho do campo, você pode fazer isto entre -colchetes logo após o tipo do campo, ex.:: - - $ bin/cake bake migration AddFullDescriptionToProducts full_description:string[60] - -Executar o comando acima irá gerar:: - - table('products'); - $table->addColumn('full_description', 'string', [ - 'default' => null, - 'limit' => 60, - 'null' => false, - ]) - ->update(); - } - } - -Se o tamanho não for especificado, os seguintes padrões serão utilizados: - -* string: 255 -* integer: 11 -* biginteger: 20 - -Alterar uma coluna de uma tabela ------------------------------------ - -Da mesma maneira, você pode gerar uma migração para alterar uma coluna usando a -linha de comando, se o nome da migração estiver no formato "X""AlterXXXOnYYY": - -.. code-block:: bash - - bin/cake bake migration AlterPriceOnProducts name:float - -Cria o arquivo:: - - table('products'); - $table->changeColumn('name', 'float'); - $table->update(); - } - } - -Removendo uma coluna de uma tabela ----------------------------------- - -Da mesma forma, você pode gerar uma migração para remover uma coluna -utilizando a linha de comando, se o nome da migração estiver na forma -"RemoveXXXFromYYY":: - - $ bin/cake bake migration RemovePriceFromProducts price - -Cria o arquivo:: - - table('products'); - $table->removeColumn('price'); - } - } - -Gerando migrações a partir de uma base de dados existente -========================================================= - -Se você está trabalhando com um banco de dados pré-existente e quer começar -a usar migrações, ou para versionar o schema inicial da base de dados da sua -aplicação, você pode executar o comando ``migration_snapshot``:: - - $ bin/cake bake migration_snapshot Initial - -Isto irá gerar um arquivo de migração chamado **YYYYMMDDHHMMSS_Initial.php** -contendo todas as instruções CREATE para todas as tabelas no seu banco de dados. - -Por padrão, o snapshot será criado a partir da conexão ``default`` definida na -configuração. -Se você precisar fazer o bake de um snapshot de uma fonte de dados diferente, -você pode utilizar a opção ``--connection``:: - - $ bin/cake bake migration_snapshot Initial --connection my_other_connection - -Você também pode definir que o snapshot inclua apenas as tabelas para as quais -você tenha definido models correspendentes, utilizando a flag -``require-table``:: - - $ bin/cake bake migration_snapshot Initial --require-table - -Quando utilizar a flag ``--require-table``, o shell irá olhar através das -classes do diretório ``Table`` da sua aplicação e apenas irá adicionar no -snapshot as tabelas lá definidas. - -A mesma lógica será aplicada implicitamente se você quiser fazer o bake de um -snapshot para um plugin. Para fazer isso, você precisa usar a opção -``--plugin``, veja a seguir:: - - $ bin/cake bake migration_snapshot Initial --plugin MyPlugin - -Apenas as tabelas que tiverem um objeto ``Table`` definido serão adicionadas -ao snapshot do seu plugin. - -.. note:: - - Quando fizer o bake de um snapshot para um plugin, os arquivos de migrações - serão criados no diretório **config/Migrations** do seu plugin. - -Fique atento que quando você faz o bake de um snapshot, ele é automaticamente - adicionado ao log do phinx como migrado. - -Gerando um *diff* entre dois estados da base de dados -===================================================== - -.. versionadded:: cakephp/migrations 1.6.0 - -Você pode gerar um arquivo de migração que agrupará todas as diferenças entre -dois estados de uma base de dados usando ``migration_diff``. Para fazê-lo, -você pode usar o seguinte comando:: - - $ bin/cake bake migration_diff NomeDasMigrações - -De forma a ter um ponto de comparação do estado atual da sua base de dados, a -*shell* de ``migrations`` gerará um arquivo de *dump* após cada chamada de -``migrate`` ou ``rollback``. O arquivo de *dump* é um arquivo contendo o -estado completo do esquema da sua base de dados em um determinado instante no -tempo. - -Uma vez gerado o arquivo de *dump*, cada modificação que você fizer -diretamente no seu sistema de gerenciamento da base de dados será adicionada -quando você chamar o comando ``bake migration_diff``. - -Por padrão, o *diff* será criado através de uma conexão com a base de dados -definida na configuração de conexão ``default``. -Se você precisar criar um *diff* de uma fonte de dados diferente, você pode -usar a opção ``--connection``:: - - $ bin/cake bake migration_diff NomeDasMigrações --connection minha_outra_conexão - -Se você quiser usar a funcionalidade de *diff* em uma aplicação que já possui -um histórico de migrações, você precisará criar manualmente o arquivo de -*dump* a ser usado como base da comparação:: - - $ bin/cake migrations dump - -O estado da base de dados deve ser o mesmo que você teria caso você tivesse -migrado todas as suas migrações antes de criar o arquivo de *dump*. -Uma vez que o arquivo de *dump* for gerado, você pode começar a fazer -modificações na sua base de dados e usar o comando ``bake migration_diff`` -sempre que desejar. - -.. note:: - - A *shell* de migrações não é capaz de detectar colunas renomeadas. - -Os Comandos -=========== - -``migrate`` : Aplicando migrações ---------------------------------- - -Depois de ter gerado ou escrito seu arquivo de migração, você precisa executar -o seguinte comando para aplicar as mudanças a sua base de dados:: - - # Executa todas as migrações - $ bin/cake migrations migrate - - # Execute uma migração específica utilizando a opção ``--target`` ou ``-t`` - # O valor é um timestamp que serve como prefixo para cada migração:: - $ bin/cake migrations migrate -t 20150103081132 - - # Por padrão, as migrações ficam no diretório **config/Migrations**. Você - # pode especificar um diretório utilizando a opção ``--source`` ou ``-s``. - # O comando abaixo executa as migrações no diretório **config/Alternate** - $ bin/cake migrations migrate -s Alternate - - # Você pode executar as migrações de uma conexão diferente da ``default`` - # utilizando a opção ``--connection`` ou ``-c``. - $ bin/cake migrations migrate -c my_custom_connection - - # Migrações também podem ser executadas para plugins. Simplesmente utilize - # a opção ``--plugin`` ou ``-p`` - $ bin/cake migrations migrate -p MyAwesomePlugin - -``rollback`` : Revertendo migrações ------------------------------------ - -O comando rollback é utilizado para desfazer migrações realizadas anteriormente -pelo plugin Migrations. É o inverso do comando ``migrate``:: - - # Você pode desfazer uma migração anterior utilizando o - # comando ``rollback``:: - $ bin/cake migrations rollback - - # Você também pode passar a versão da migração para voltar - # para uma versão específica:: - $ bin/cake migrations rollback -t 20150103081132 - -Você também pode utilizar as opções ``--source``, ``--connection`` e -``--plugin`` exatamente como no comando ``migrate``. - -``status`` : Status da migração -------------------------------- - -O comando status exibe uma lista de todas as migrações juntamente com seu -status. Você pode utilizar este comando para ver quais migrações foram -executadas:: - - $ bin/cake migrations status - -Você também pode ver os resultados como JSON utilizando a opção -``--format`` (ou ``-f``):: - - $ bin/cake migrations status --format json - -Você também pode utilizar as opções ``--source``, ``--connection`` e -``--plugin`` exatamente como no comando ``migrate``. - -``mark_migrated`` : Marcando uma migração como migrada ------------------------------------------------------- - -.. versionadded:: 1.4.0 - -Algumas vezes pode ser útil marcar uma lista de migrações como migrada sem -efetivamente executá-las. -Para fazer isto, você pode usar o comando ``mark_migrated``. O comando é -bastante semelhante aos outros comandos. - -Você pode marcar todas as migrações como migradas utilizando este comando:: - - $ bin/cake migrations mark_migrated - -Você também pode marcar todas as migrações de uma versão específica -utilizando a opção ``--target``:: - - $ bin/cake migrations mark_migrated --target=20151016204000 - -Se você não quer marcar a migração alvo como migrada durante o processo, você -pode utilizar a opção ``--exclude``:: - - $ bin/cake migrations mark_migrated --target=20151016204000 --exclude - -Finalmente, se você deseja marcar somente a migração alvo como migrada, -você pode utilizar a opção ``--only``:: - - $ bin/cake migrations mark_migrated --target=20151016204000 --only - -Você também pode utilizar as opções ``--source``, ``--connection`` e -``--plugin`` exatamente como no comando ``migrate``. - -.. note:: - - Quando você criar um snapshot utilizando o bake com o comando - ``cake bake migration_snapshot``, a migração criada será automaticamente - marcada como migrada. - -.. deprecated:: 1.4.0 - - A seguinte maneira de utilizar o comando foi depreciada. Use somente se - você estiver utilizando uma versão do plugin inferior a 1.4.0. - -Este comando espera um número de versão de migração como argumento:: - - $ bin/cake migrations mark_migrated - -Se você deseja marcar todas as migrações como migradas, você pode utilizar -o valor especial ``all``. Se você o utilizar, ele irá marcar todas as migrações -como migradas:: - - $ bin/cake migrations mark_migrated all - -``seed`` : Populando seu banco de dados ---------------------------------------- - -A partir da versão 1.5.5, você pode usar a **shell** de ``migrations`` para -popular seu banco de dados. Essa função é oferecida graças ao -`recurso de seed da biblioteca Phinx `_. -Por padrão, arquivos **seed** ficarão no diretório ``config/Seeds`` de sua -aplicação. Por favor, tenha certeza de seguir as -`instruções do Phinx para construir seus arquivos de seed `_. - -Assim como nos **migrations**, uma interface do ```bake`` é oferecida para gerar -arquivos de **seed**:: - - # This will create a ArticlesSeed.php file in the directory config/Seeds of your application - # By default, the table the seed will try to alter is the "tableized" version of the seed filename - $ bin/cake bake seed Articles - - # You specify the name of the table the seed files will alter by using the ``--table`` option - $ bin/cake bake seed Articles --table my_articles_table - - # You can specify a plugin to bake into - $ bin/cake bake seed Articles --plugin PluginName - - # You can specify an alternative connection when generating a seeder. - $ bin/cake bake seed Articles --connection connection - -.. versionadded:: cakephp/migrations 1.6.4 - - As opções ``--data``, ``--limit`` e ``--fields`` foram adicionadas para - exportar dados da sua base de dados. - -A partir da versão 16.4, o comando ``bake seed`` permite que você crie um -arquivo de *seed* com dados exportados da sua base de dados com o uso da -*flag* ``--data``:: - - $ bin/cake bake seed --data Articles - -Por padrão, esse comando exportará todas as linhas encontradas na sua -tabela. Você pode limitar o número de linhas a exportar usando a opção -``--limit``:: - - # Exportará apenas as 10 primeiras linhas encontradas - $ bin/cake bake seed --data --limit 10 Articles - -Se você deseja incluir apenas uma seleção dos campos da tabela no seu -arquivo de *seed*, você pode usar a opção ``--fields``. Ela recebe a -lista de campos a incluir na forma de uma *string* separada por -vírgulas:: - - # Exportará apenas os campos `id`, `title` e `excerpt` - $ bin/cake bake seed --data --fields id,title,excerpt Articles - -.. tip:: - - Você pode utilizar ambas as opções ``--limit`` e ``--fields`` - simultaneamente em uma mesma chamada. - -Para popular seu banco de dados, você pode usar o subcomando ``seed``:: - - # Without parameters, the seed subcommand will run all available seeders - # in the target directory, in alphabetical order. - $ bin/cake migrations seed - - # You can specify only one seeder to be run using the `--seed` option - $ bin/cake migrations seed --seed ArticlesSeed - - # You can run seeders from an alternative directory - $ bin/cake migrations seed --source AlternativeSeeds - - # You can run seeders from a plugin - $ bin/cake migrations seed --plugin PluginName - - # You can run seeders from a specific connection - $ bin/cake migrations seed --connection connection - -Esteja ciente que, ao oposto das **migrations**, **seeders** não são -versionados, o que significa que o mesmo **seeder** pode ser aplicado diversas -vezes. - -Usando migrations em plugins -============================ - -**Plugins** também podem oferecer **migrations**. Isso faz com que **plugins** -que são planejados para serem distribuídos tornem-se muito mais práticos e -fáceis de instalar. Todos os comandos do plugin **Migrations** suportam a opção -``--plugin`` ou ``-p``, que por sua vez vai delegar a execução da tarefa ao -escopo relativo a um determinado **plugin**:: - - $ bin/cake migrations status -p PluginName - - $ bin/cake migrations migrate -p PluginName - -Executando migrations em ambientes fora da linha de comando -=========================================================== - -.. versionadded:: cakephp/migrations 1.2.0 - -Desde o lançamento da versão 1.2 do plugin, você pode executar **migrations** -fora da linha de comando, diretamente de uma aplicação, ao usar a nova classe -``Migrations``. Isso pode ser muito útil caso você esteja desenvolvendo um -instalador de **plugins** para um CMS, para exemplificar. - -A classe ``Migrations`` permite que você execute os seguintes comandos -disponíveis na **shell**: - -* migrate -* rollback -* markMigrated -* status -* seed - -Cada um desses comandos tem um método definido na classe ``Migrations``. - -Veja como usá-la:: - - use Migrations\Migrations; - - $migrations = new Migrations(); - - // Retornará um array de todos migrations e seus status - $status = $migrations->status(); - - // Retornará true se bem sucedido. Se um erro ocorrer, uma exceção será lançada - $migrate = $migrations->migrate(); - - // Retornará true se bem sucedido. Se um erro ocorrer, uma exceção será lançada - $rollback = $migrations->rollback(); - - // Retornará true se bem sucedido. Se um erro ocorrer, uma exceção será lançada - $markMigrated = $migrations->markMigrated(20150804222900); - - // Retornará true se bem sucedido. Se um erro ocorrer, uma exceção será lançada - $seeded = $migrations->seed(); - -Os métodos aceitam um **array** de parâmetros que devem combinar com as opções -dos comandos:: - - use Migrations\Migrations; - - $migrations = new Migrations(); - - // Retornará um array de todos migrations e seus status - $status = $migrations->status(['connection' => 'custom', 'source' => 'MyMigrationsFolder']); - -Você pode passar qualquer opção que esteja disponível pelos comandos **shell**. -A única exceção é o comando ``markMigrated`` que espera um número de versão a -ser marcado como migrado, como primeiro argumento. Passe o **array** de -parâmetros como segundo argumento nesse caso. - -Opcionalmente, você pode passar esses parâmetros pelo construtor da classe. -Eles serão usados como padrão evitando que você tenha que passá-los em cada -chamada do método:: - - use Migrations\Migrations; - - $migrations = new Migrations(['connection' => 'custom', 'source' => 'MyMigrationsFolder']); - - // Todas as chamadas de métodos serão executadas usando os parâmetros passados pelo construtor da classe - $status = $migrations->status(); - $migrate = $migrations->migrate(); - -Se você precisar sobrescrever um ou mais parâmetros definidos previamente, você -pode passá-los para um método:: - - use Migrations\Migrations; - - $migrations = new Migrations(['connection' => 'custom', 'source' => 'MyMigrationsFolder']); - - // Essa chamada será feita com a conexão "custom" - $status = $migrations->status(); - // Essa chamada será feita com a conexão "default" - $migrate = $migrations->migrate(['connection' => 'default']); - -Dicas e truques -=============== - -Criando chaves primárias customizadas -------------------------------------- - -Se você precisa evitar a criação automática da chave primária ``id`` ao -adicioanr novas tabelas ao banco de dados, é possível usar o segundo argumento -do método ``table()``:: - - table('products', ['id' => false, 'primary_key' => ['id']]); - $table - ->addColumn('id', 'uuid') - ->addColumn('name', 'string') - ->addColumn('description', 'text') - ->create(); - } - } - -O código acima vai criar uma coluna ``CHAR(36)`` ``id`` que também é a chave -primária. - -.. note:: - - Ao especificar chaves primárias customizadas pela linha de comando, você - deve apontá-las como chave primária no campo id, caso contrário você pode - receber um erro apontando campos diplicados, i.e.:: - - $ bin/cake bake migration CreateProducts id:uuid:primary name:string description:text created modified - -Adicionalmente, desde a versão 1.3, uma novo meio de lidar com chaves primárias -foi introduzido. Para tal, sua classe de migração deve estender a nova classe -``Migrations\AbstractMigration``. - -Você pode especificar uma propriedade ``autoId`` na sua classe e defini-la como -``false``, o quê desabilitará a geração automática da coluna ``id``. Você -vai precisar criar manualmente a coluna que será usada como chave primária e -adicioná-la à declaração da tabela:: - - table('products'); - $table - ->addColumn('id', 'integer', [ - 'autoIncrement' => true, - 'limit' => 11 - ]) - ->addPrimaryKey('id') - ->addColumn('name', 'string') - ->addColumn('description', 'text') - ->create(); - } - } - -Comparado ao método apresentado anteriormente de lidar com chaves primárias, -nesse método, temos a possibilidade de ter maior controle sobre as definições -da coluna da chave primária: -unsigned, limit, comentários, etc. - -Todas as migrations e snapshots criadas pelo bake vão usar essa nova forma -quando necessário. - -.. warning:: - - Lidar com chaves primárias só é possível no momento de criação de tabelas. - Isso é devido a algumas limitações de alguns servidores de banco de dados - que o plugin suporta. - -Colações --------- - -Se você precisar criar uma tabela com colação diferente do padrão do banco de -dados, você pode defini-la pelo método ``table()``, como uma opção:: - - table('categories', [ - 'collation' => 'latin1_german1_ci' - ]) - ->addColumn('title', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]) - ->create(); - } - } - -Note que isso só pode ser feito na criação da tabela : não há atualmente uma -forma de adicionar uma coluna a uma tabela existente com uma colação diferente -do padrão da tabela, ou mesmo do banco de dados. -Apenas ``MySQL`` e ``SqlServer`` suportam essa chave de configuração. - -Atualizando nome de colunas e usando objetos de tabela ------------------------------------------------------- - -Se você usa um objeto ORM Table do CakePHP para manipular valores do seu banco -de dados, renomeando ou removendo uma coluna, certifique-se de criar uma nova -instância do seu objeto depois da chamada do ``update()``. O registro do objeto -é limpo depois da chamada do ``update()`` para atualizar o **schema** que é -refletido e armazenado no objeto ``Table`` paralelo à instanciação. - -Migrations e Deployment ------------------------ - -Se você usa o plugin ao fazer o **deploy** de sua aplicação, garanta que o cache -ORM seja limpo para renovar os metadados das colunas de suas tabelas. -Caso contrário, você pode acabar recebendo erros relativos a colunas -inexistentes ao criar operações nessas mesmas colunas. -O **core** do CakePHP possui uma -`Schema Cache Shell `__ -que você pode usar para realizar essas operação:: - - $ bin/cake schema_cache clear - -Leia a seção `Schema Cache Shell -`__ do cookbook -se você quiser conhecer mais sobre essa **shell**. diff --git a/docs/ru/conf.py b/docs/ru/conf.py deleted file mode 100644 index f8a170ee5..000000000 --- a/docs/ru/conf.py +++ /dev/null @@ -1,9 +0,0 @@ -import sys, os - -# Append the top level directory of the docs, so we can import from the config dir. -sys.path.insert(0, os.path.abspath('..')) - -# Pull in all the configuration options defined in the global config file.. -from config.all import * - -language = 'ru' diff --git a/docs/ru/contents.rst b/docs/ru/contents.rst deleted file mode 100644 index 1459b1f1b..000000000 --- a/docs/ru/contents.rst +++ /dev/null @@ -1,5 +0,0 @@ -.. toctree:: - :maxdepth: 2 - :caption: CakePHP Migrations - - /index diff --git a/docs/ru/index.rst b/docs/ru/index.rst deleted file mode 100644 index 92f1a7e1a..000000000 --- a/docs/ru/index.rst +++ /dev/null @@ -1,1020 +0,0 @@ -Миграции -######## - -Миграции (Migrations) - это плагин, поддерживаемый основной командой, который помогает вам выполнять -изменение схемы вашей базе данных путём написания файлов PHP, которые можно отслеживать с помощью -системы управления версиями. - -Это позволяет вам постепенно менять таблицы базы данных. Вместо написания -модификации схемы в виде SQL, этот плагин позволяет вам использовать интуитивно -понятный набор методов для изменения вашей базы данных. - -Этот плагин является обёрткой для библиотеки миграции баз данных `Phinx `_. - -Установка -========= - -По умолчанию Migrations устанавливается вместе с дефолтным скелетом приложения. -Если вы удалили его и хотите его переустановить, вы можете сделать это, запустив -следующее из каталога ROOT вашего приложения (где находится файл composer.json): - -.. code-block:: bash - - $ php composer.phar require cakephp/migrations "@stable" - - # Или, если композитор установлен глобально - - $ composer require cakephp/migrations "@stable" - -Чтобы использовать плагин, вам нужно загрузить его в файле **config/bootstrap.php** -вашего приложения. Вы можете использовать -`CakePHP's Plugin shell -`__ для загрузки и выгрузки плагинов из -вашего **config/bootstrap.php**:: - - $ bin/cake plugin load Migrations - -Или вы можете загрузить плагин, отредактировав файл **config/bootstrap.php** -и добавив следующий оператор:: - - Plugin::load('Migrations'); - -Кроме того, вам нужно будет настроить конфигурацию базы данных по умолчанию для вашего -приложения в файле **config/app.php**, как описано в -`Раздел о конфигурации БД -`__. - -Обзор -===== - -Миграция в основном представляет собой один файл PHP, который описывает изменения -для работы с базой данных. Файл миграции может создавать или удалять таблицы, -добавлять или удалять столбцы, создавать индексы и даже вставлять данные в вашу базу данных. - -Вот пример миграции:: - - table('products'); - $table->addColumn('name', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]); - $table->addColumn('description', 'text', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('created', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('modified', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->create(); - } - } - -Эта миграция добавит таблицу в вашу базу данных под названием ``products`` -со следующими определениями столбцов: - -- ``id`` столбец типа ``integer`` как primary key (первичный ключ) -- ``name`` столбец типа ``string`` -- ``description`` столбец типа ``text`` -- ``created`` столбец типа ``datetime`` -- ``modified`` столбец типа ``datetime`` - -.. tip:: - - Столбец первичного ключа с именем ``id`` будет добавлен **неявно**. - -.. note:: - - Обратите внимание, что этот файл описывает, как будет выглядеть база - данных **после** применения миграции. На данный момент в вашей базе - данных нет таблицы ``products``, мы просто создали файл, который способен - создавать таблицу ``products`` с указанными столбцами, а также удалить её, - когда выполняется ``rollback`` операция миграции. - -После того, как файл был создан в папке **config/Migrations**, вы сможете -выполнить следующую команду ``migrations``, чтобы создать таблицу в своей -базе данных:: - - bin/cake migrations migrate - -Следующая команда ``migrations`` выполнит ``rollback`` и удалит эту таблицу -из вашей базы данных:: - - bin/cake migrations rollback - -Создание миграций -================= - -Файлы миграции хранятся в каталоге **config/Migrations** вашего приложения. -Имя файлов миграции имеет префикс даты, в которой они были созданы, в -формате **YYYYMMDDHHMMSS_MigrationName.php**. Ниже приведены примеры имён -файлов миграции: - -* 20160121163850_CreateProducts.php -* 20160210133047_AddRatingToProducts.php - -Самый простой способ создать файл миграции - это использовать команду CLI -``bin/cake bake migration``. - -Пожалуйста, убедитесь, что вы читали официальную -`Phinx documentation `_ -чтобы узнать полный список методов, которые вы можете использовать для -записи файлов миграции. - -.. note:: - - При использовании опции ``bake`` вы всё равно можете изменить миграции, - прежде чем запускать их, если это необходимо. - -Синтаксис ---------- - -Синтаксис команды ``bake`` следует форме ниже:: - - $ bin/cake bake migration CreateProducts name:string description:text created modified - -При использовании ``bake`` для создания таблиц, добавления столбцов и т. п. в -вашей базе данных, вы обычно предоставляете две вещи: - -* имя миграции, которую вы создадите (``CreateProducts`` в нашем примере) -* столбцы таблицы, которые будут добавлены или удалены в процессе миграции - (``name: string description: text created modified`` в нашем примере) - -В связи с соглашениями CakePHP, не все изменения схемы могут выполняться с помощью этих -команд оболочки. - -Кроме того, вы можете создать пустой файл миграции, если хотите получить полный контроль -над тем, что нужно выполнить, указав определение столбцов:: - - $ bin/cake migrations create MyCustomMigration - -Имя файла миграции -~~~~~~~~~~~~~~~~~~ - -Имена миграции могут следовать любому из следующих шаблонов: - -* (``/^(Create)(.*)/``) Создаёт указанную таблицу. -* (``/^(Drop)(.*)/``) Уничтожает указанную таблицу. - Игнорирует аргументы заданного поля. -* (``/^(Add).*(?:To)(.*)/``) Добавляет поля в указанную таблицу. -* (``/^(Remove).*(?:From)(.*)/``) Удаляет поля из указанной таблицы. -* (``/^(Alter)(.*)/``) Изменяет указанную таблицу. Псевдоним для - CreateTable и AddField. -* (``/^(Alter).*(?:On)(.*)/``) Изменяет поля указанной таблицы. - -Вы также можете использовать ``underscore_form`` как имя для своих миграций, например -``create_products``. - -.. versionadded:: cakephp/migrations 1.5.2 - - Начиная с версии 1.5.2 `migrations plugin `_, - имя файла миграции будет автоматически изменено. Эта версия плагина доступна только - с выпуском CakePHP> = to 3.1. До этой версии плагина имя миграции было бы в форме - подчеркивания, то есть ``20160121164955_create_products.php``. - -.. warning:: - - Имена миграции используются как имена классов миграции и, таким образом, - могут сталкиваться с другими миграциями, если имена классов не уникальны. - В этом случае может потребоваться вручную переопределить имя на более - позднюю дату или просто изменить имя, которое вы указываете. - -Определение столбцов -~~~~~~~~~~~~~~~~~~~~ - -При использовании столбцов в командной строке может быть удобно запомнить, что они -используют следующий шаблон:: - - fieldName:fieldType?[length]:indexType:indexName - -Например, все допустимые способы указания поля электронной почты: - -* ``email:string?`` -* ``email:string:unique`` -* ``email:string?[50]`` -* ``email:string:unique:EMAIL_INDEX`` -* ``email:string[120]:unique:EMAIL_INDEX`` - -Знак вопроса, следующий за типом fieldType, сделает столбец нулевым. - -Параметр ``length`` для ``fieldType`` является необязательным и всегда должен быть -записан в скобках. - -Поля с именем ``created`` и ``modified``, а также любое поле с суффиксом ``_at`` -автоматически будут установлены в тип ``datetime``. - -Типы полей поддерживаемые библиотекой ``Sphinx``: - -* string -* text -* integer -* biginteger -* float -* decimal -* datetime -* timestamp -* time -* date -* binary -* boolean -* uuid - -Существуют некоторые эвристики для выбора типов полей, если они не указаны или -установлено недопустимое значение. Тип поля по умолчанию - ``string``: - -* id: integer -* created, modified, updated: datetime - -Создание таблицы ----------------- - -Вы можете использовать ``bake`` для создания таблицы:: - - $ bin/cake bake migration CreateProducts name:string description:text created modified - -В приведённой выше командной строке будет создан файл миграции, напоминающий:: - - table('products'); - $table->addColumn('name', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]); - $table->addColumn('description', 'text', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('created', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->addColumn('modified', 'datetime', [ - 'default' => null, - 'null' => false, - ]); - $table->create(); - } - } - -Добавление столбцов в существующую таблицу ------------------------------------------- - -Если имя миграции в командной строке имеет форму "AddXXXToYYY" и за ней следует -список имён столбцов и типов, тогда будет создан файл миграции, содержащий код -для создания столбцов:: - - $ bin/cake bake migration AddPriceToProducts price:decimal - -Выполнение приведенной выше командной строки сгенерирует:: - - table('products'); - $table->addColumn('price', 'decimal') - ->update(); - } - } - -Добавление столбца в качестве индекса в таблицу ------------------------------------------------ - -Также можно добавлять индексы в столбцы:: - - $ bin/cake bake migration AddNameIndexToProducts name:string:index - -будет сгенерировано:: - - table('products'); - $table->addColumn('name', 'string') - ->addIndex(['name']) - ->update(); - } - } - -Указание длины поля -------------------- - -.. versionadded:: cakephp/migrations 1.4 - -Если вам нужно указать длину поля, вы можете сделать это в квадратных скобках -в поле типа:: - - $ bin/cake bake migration AddFullDescriptionToProducts full_description:string[60] - -Выполнение приведенной выше командной строки будет генерировать:: - - table('products'); - $table->addColumn('full_description', 'string', [ - 'default' => null, - 'limit' => 60, - 'null' => false, - ]) - ->update(); - } - } - -Если длина не указана, значения длины для определённого типа столбцов установятся -по умолчания как: - -* string: 255 -* integer: 11 -* biginteger: 20 - -Изменить столбец из таблицы ------------------------------------ - -Таким же образом вы можете сгенерировать миграцию для изменения столбца с помощью -командной строки, если имя миграции имеет вид "AlterXXXOnYYY": - -.. code-block:: bash - - bin/cake bake migration AlterPriceOnProducts name:float - -создаст файл:: - - table('products'); - $table->changeColumn('name', 'float'); - $table->update(); - } - } - -Удаление столбца из таблицы ---------------------------- - -Аналогичным образом вы можете сгенерировать миграцию для удаления столбца с помощью -командной строки, если имя миграции имеет форму "RemoveXXXFromYYY":: - - $ bin/cake bake migration RemovePriceFromProducts price - -создаст файл:: - - table('products'); - $table->removeColumn('price') - ->save(); - } - } - -.. note:: - - Команда `removeColumn` не является обратимой, поэтому её нужно вызывать - в методе `up`. Соответствующий вызов `addColumn` должен быть добавлен к - методу `down`. - -Создание миграции для существующей базы данных -============================================== - -Если вы имеете дело с уже существующей базой данных и хотите начать -использовать миграцию или управлять версией исходной схемы базы данных -вашего приложения, вы можете запустить команду ``migration_snapshot``:: - - $ bin/cake bake migration_snapshot Initial - -Это заставит сгенерировать файл миграции с именем **YYYYMMDDHHMMSS_Initial.php**, -содержащий все инструкции create для всех таблиц в вашей базе данных. - -По умолчанию, моментальный снимок будет создан путём подключения к базе данных, -определённой в ``default`` конфигурации подключения. - -Если же вам нужно создать снимок из другого источника данных (из другой настройки), -вы можете использовать опцию ``--connection``:: - - $ bin/cake bake migration_snapshot Initial --connection my_other_connection - -Вы также можете убедиться, что моментальный снимок содержит только те таблицы, -для которых вы определили соответствующие классы моделей, используя флаг -``--require-table``:: - - $ bin/cake bake migration_snapshot Initial --require-table - -При использовании флага ``--require-table`` оболочка будет просматривать классы -вашего приложения ``Table`` и будет добавлять таблицы модели в моментальный снимок. - -Эта же логика будет применяться неявно, если вы хотите создать снимок для плагина. -Для этого вам нужно использовать опцию ``--plugin``:: - - $ bin/cake bake migration_snapshot Initial --plugin MyPlugin - -В моментальный снимок вашего плагина будут добавлены только те таблицы, у которых -есть класс объектной модели ``Table``. - -.. note:: - - При создании моментального снимка для плагина, файлы миграции будут созданы - в каталоге **config/Migrations** вашего плагина. - -Имейте в виду, что когда вы создаёте моментальный снимок, он автоматически -добавляется в таблицу журналов sphinx как перенесённый. - -Создание разницы между двумя состояниями базы данных -==================================================== - -.. versionadded:: cakephp/migrations 1.6.0 - -Вы можете создать файл миграции, в котором будут группироваться все различия -между двумя состояниями базы данных с использованием шаблона ``migration_diff``. -Для этого вы можете использовать следующую команду:: - - $ bin/cake bake migration_diff NameOfTheMigrations - -Чтобы иметь точку сравнения с текущим состоянием базы данных, оболочка миграции -будет генерировать файл "дампа" после каждого вызова ``migrate`` или -``rollback``. Файл дампа - это файл, содержащий полное состояние схемы вашей -базы данных в данный момент времени. - -После создания дамп-файла все изменения, которые вы делаете непосредственно -в вашей системе управления базой данных, будут добавлены в файл миграции, -сгенерированный при вызове команды ``bake migration_diff``. - -По умолчанию diff будет создан путём подключения к базе данных, определенной -в конфигурации ``default``. Если вам нужно испечь diff от другого источника -данных, вы можете использовать опцию ``--connection``:: - - $ bin/cake bake migration_diff NameOfTheMigrations --connection my_other_connection - -Если вы хотите использовать функцию diff в приложении, которое уже имеет историю -миграции, вам необходимо вручную создать файл дампа, который будет использоваться -в качестве сравнения:: - - $ bin/cake migrations dump - -Состояние базы данных должно быть таким же, как если бы вы просто перенесли все -свои миграции перед созданием файла дампа. После создания файла дампа вы можете -начать делать изменения в своей базе данных и использовать команду -``bake migration_diff`` всякий раз, когда вы считаете нужным. - -.. note:: - - Оболочка миграций не может обнаруживать переименования столбцов. - -Команды -======= - -``migrate`` : Применение миграции ---------------------------------- - -После создания или записи файла миграции вам необходимо выполнить одну из -следующих команд, чтобы применить изменения в своей базе данных:: - - # Запуск всех миграций - $ bin/cake migrations migrate - - # Миграция к определённой версии, используя опцию ``--target`` - # или ``-t`` для краткости. - # Значение - это метка времени, которая имеет префикс имени файла миграции:: - $ bin/cake migrations migrate -t 20150103081132 - - # По умолчанию файлы миграции ищются в каталоге **config/Migrations**. - # Вы можете указать альтернативный каталог, используя опцию ``--source`` - # или ``-s`` для краткости. - # В следующем примере будут выполняться миграции в каталоге - # **config/Alternate** - $ bin/cake migrations migrate -s Alternate - - # Вы можете запускать миграции используя другое соединение, чем ``default``, - # для этого используйте опцию ``--connection`` или ``-c`` для краткости. - $ bin/cake migrations migrate -c my_custom_connection - - # Миграции также могут выполняться для плагинов. Просто используйте опцию - # ``--plugin`` или ``-p`` для краткости. - $ bin/cake migrations migrate -p MyAwesomePlugin - -``rollback`` : Откат миграций ------------------------------ - -Команда Rollback используется для отмены предыдущих миграций, выполняемых -этим плагином. Это обратное действие по отношения к команде ``migrate``:: - - # Вы можете вернуться к предыдущей миграции, используя команду - # ``rollback``:: - $ bin/cake migrations rollback - - # Вы также можете передать номер версии миграции для отката - # к определённой версии:: - $ bin/cake migrations rollback -t 20150103081132 - -Вы также можете использовать параметры ``--source``, ``--connection`` -и ``--plugin``, как и для ``migrate``. - -``status`` : Статус миграции ----------------------------- - -Команда Status выводит список всех миграций вместе с их текущим статусом. -Вы можете использовать эту команду, чтобы определить, какие миграции были -выполнены:: - - $ bin/cake migrations status - -Вы также можете выводить результаты как форматированную JSON строку, -используя опцию ``--format`` или ``-f`` для краткости.:: - - $ bin/cake migrations status --format json - -Вы также можете использовать параметры ``--source``, ``--connection`` -и ``--plugin``, как и для ``migrate``. - -``mark_migrated`` : Пометка миграций как перенесённые ------------------------------------------------------ - -.. versionadded:: 1.4.0 - -Иногда бывает полезно отметить набор миграций, перенесённых без их -фактического запуска. Для этого вы можете использовать команду -``mark_migrated``. Команда работает плавно, как и другие команды. - -Вы можете пометить все миграции как перенесенные с помощью этой команды:: - - $ bin/cake migrations mark_migrated - -Вы также можете пометить все миграции до определённой версии как перенесенные -с помощью параметра ``--target``:: - - $ bin/cake migrations mark_migrated --target=20151016204000 - -Если вы не хотите, чтобы целевая миграция была помечена как перенесённая во -время процесса миграции, вы можете использовать флаг ``--exclude``:: - - $ bin/cake migrations mark_migrated --target=20151016204000 --exclude - -Наконец, если вы хотите пометить только перенесённую миграцию, вы можете -использовать флаг ``--only``:: - - $ bin/cake migrations mark_migrated --target=20151016204000 --only - -Вы также можете использовать параметры ``--source``, ``--connection`` -и ``--plugin``, как и для ``migrate``. - -.. note:: - - Когда вы выпекаете моментальный снимок с помощью команды - ``cake bake migration_snapshot``, созданная миграция будет автоматически - помечена как перенесенная. - -.. deprecated:: 1.4.0 - - Следующий способ использования команды устарел. Используйте его - только в том случае, если вы используете версию плагина < 1.4.0. - -Эта команда ожидает номер версии миграции в качестве аргумента:: - - $ bin/cake migrations mark_migrated 20150420082532 - -Если вы хотите пометить все миграции как перенесенные, вы можете использовать -специальное значение ``all``. Если вы используете его, оно будет отмечать все -найденные миграции как перенесенные:: - - $ bin/cake migrations mark_migrated all - -``seed`` : Засеивание базы данных ---------------------------------- - -Начиная с 1.5.5, вы можете использовать оболочку ``migrations`` для засеивания -вашей базы данных. Это использует -`Phinx library seed feature `_. -По умолчанию файлы семян будут искать в каталоге ``config/Seeds`` вашего приложения. -Пожалуйста, убедитесь, что вы следуете -`Phinx instructions to build your seed files `_. - -Что касается миграций, для файлов семян предоставляется интерфейс ``bake``:: - - # Это создаст файл ArticlesSeed.php в каталоге config/Seeds вашего приложения. - # По умолчанию таблица, которую семя будет пытаться изменить, является "табличной" - # версией имени файла семени. - $ bin/cake bake seed Articles - - # Вы указываете имя таблицы, которую будут изменять семенные файлы, - # используя опцию ``--table`` - $ bin/cake bake seed Articles --table my_articles_table - - # Вы можете указать плагин для выпечки - $ bin/cake bake seed Articles --plugin PluginName - - # Вы можете указать альтернативное соединение при создании сеялки. - $ bin/cake bake seed Articles --connection connection - -.. versionadded:: cakephp/migrations 1.6.4 - - Для экспорта данных из базы данных были добавлены опции ``--data``, - ``--limit`` и ``--fields``. - -Начиная с версии 1.6.4 команда ``bake seed`` позволяет создать файл семян с данными, -экспортированными из вашей базы данных, с помощью флага ``--data``:: - - $ bin/cake bake seed --data Articles - -По умолчанию он будет экспортировать все строки, найденные в вашей таблице. -Вы можете ограничить количество строк, экспортированных с помощью опции -``-limit``:: - - # Будет экспортировано только первые 10 найденных строк - $ bin/cake bake seed --data --limit 10 Articles - -Если вы хотите включить только поле из таблицы в файл семени, вы можете -использовать опцию ``--fields``. Она принимает список полей для включения -в виде строки значений, разделенных запятой:: - - # Будет экспортировать только поля `id`, `title` и `excerpt` - $ bin/cake bake seed --data --fields id,title,excerpt Articles - -.. tip:: - - Конечно, вы можете использовать оба параметра ``--limit`` и ``--fields`` - в том же командном вызове. - -Чтобы засеять вашу базу данных, вы можете использовать подкоманду ``seed``:: - - # Без параметров подкоманда seed будет запускать все доступные сеялки - # в целевом каталоге, в алфавитном порядке. - $ bin/cake migrations seed - - # Вы можете указать только одну сеялку для запуска с использованием - # опции `--seed` - $ bin/cake migrations seed --seed ArticlesSeed - - # Вы можете запускать сеялки из альтернативного каталога - $ bin/cake migrations seed --source AlternativeSeeds - - # Вы можете запускать сеялки из плагина - $ bin/cake migrations seed --plugin PluginName - - # Вы можете запускать сеялки из определённого соединения - $ bin/cake migrations seed --connection connection - -Имейте в виду, что в отличие от миграций сеялки не отслеживаются, а это -означает, что одну и ту же сеялку можно применять несколько раз. - -Вызов сеялки из другой сеялки -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. versionadded:: cakephp/migrations 1.6.2 - -Обычно при посеве необходимо соблюдать порядок, в котором нужно вставлять данные, -чтобы не встречаться с нарушениями ограничений. Поскольку по умолчанию Seeders -выполняются в алфавитном порядке, вы можете использовать метод -``\Migrations\AbstractSeed::call()`` для определения вашей собственной -последовательности выполнения сеялок:: - - use Migrations\AbstractSeed; - - class DatabaseSeed extends AbstractSeed - { - public function run(): void - { - $this->call('AnotherSeed'); - $this->call('YetAnotherSeed'); - - // Вы можете использовать plugin dot syntax, чтобы - // вызывать сеялки из плагина - $this->call('PluginName.FromPluginSeed'); - } - } - -.. note:: - - Не забудьте расширить модуль плагина Migrations ``AbstractSeed``, если вы - хотите использовать метод ``call()``. Этот класс был добавлен с выпуском 1.6.2. - -``dump`` : Создание файла дампа для разницы выпечек ---------------------------------------------------- - -Команда Dump создаёт файл, который будет использоваться с bake шаблоном -``migration_diff``:: - - $ bin/cake migrations dump - -Каждый сгенерированный файл дампа относится к соединению, из которого он создан -(и суффикс как таковой). Это позволяет команде ``bake migration_diff`` правильно -вычислять разницу, если ваше приложение имеет дело с несколькими базами данных, -возможно, от разных поставщиков баз данных. - -Файлы дампов создаются в том же каталоге, что и файлы миграции. - -Вы также можете использовать параметры ``--source``, ``--connection`` -и ``--plugin``, как и для ``migrate``. - -Использование миграции в плагинах -================================= - -Плагины также могут предоставлять файлы миграции. Это делает плагины, которые -предназначены для распространения, гораздо более портативны и простыми в -установке. Все команды в плагине Migrations поддерживают опцию ``--plugin`` -или ``-p``, которая охватит выполнение миграции относительно этого -плагина:: - - $ bin/cake migrations status -p PluginName - - $ bin/cake migrations migrate -p PluginName - -Выполнение миграции в среде без оболочки -======================================== - -.. versionadded:: cakephp/migrations 1.2.0 - -Начиная с версии 1.2 плагина миграции вы можете запускать миграции из среды -без оболочки, непосредственно из приложения, используя новый класс ``Migrations``. -Это может быть удобно, если вы разрабатываете например инсталлятор плагинов для CMS. -Класс ``Migrations`` позволяет запускать следующие команды из оболочки миграции: - -* migrate -* rollback -* markMigrated -* status -* seed - -Каждая из этих команд имеет метод, определённый в классе ``Migrations``. - -Вот как его использовать:: - - use Migrations\Migrations; - - $migrations = new Migrations(); - - // Вернёт массив всех миграций и их статус - $status = $migrations->status(); - - // Вернёт true, если успешно. Если произошла ошибка, будет возвращено исключение - $migrate = $migrations->migrate(); - - // Вернёт true, если успешно. Если произошла ошибка, будет возвращено исключение - $rollback = $migrations->rollback(); - - // Вернёт true, если успешно. Если произошла ошибка, будет возвращено исключение - $markMigrated = $migrations->markMigrated(20150804222900); - - // Вернёт true, если успешно. Если произошла ошибка, будет возвращено исключение - $seeded = $migrations->seed(); - -Методы могут принимать массив параметров, которые должны соответствовать параметрам -из команд:: - - use Migrations\Migrations; - - $migrations = new Migrations(); - - // Вернёт массив всех миграций и их статус - $status = $migrations->status(['connection' => 'custom', 'source' => 'MyMigrationsFolder']); - -Вы можете передать любые параметры, которые потребуются командам оболочки. -Единственным исключением является команда ``markMigrated``, которая ожидает, -что номер версии миграции будет отмечен как перенесённый как первый аргумент. -Передайте массив параметров в качестве второго аргумента для этого метода. - -При желании вы можете передать эти параметры в конструкторе класса. -Они будут использоваться по умолчанию, и это не позволит вам передать их -при каждом вызове метода:: - - use Migrations\Migrations; - - $migrations = new Migrations(['connection' => 'custom', 'source' => 'MyMigrationsFolder']); - - // Все последующие вызовы будут выполнены с параметрами, переданными конструктору класса Migrations - $status = $migrations->status(); - $migrate = $migrations->migrate(); - -Если вам необходимо переопределить один или несколько параметров по умолчанию для одного вызова, -вы можете передать их методу:: - - use Migrations\Migrations; - - $migrations = new Migrations(['connection' => 'custom', 'source' => 'MyMigrationsFolder']); - - // Этот вызов будет выполнен с использованием "пользовательского" соединения - $status = $migrations->status(); - // Этот с подключением "по умолчанию" - $migrate = $migrations->migrate(['connection' => 'default']); - -Советы и приёмы -=============== - -Создание пользовательских первичных ключей ------------------------------------------- - -Если вам нужно избегать автоматического создания первичного ключа ``id`` -при добавлении новых таблиц в базу данных, вы можете использовать второй -аргумент метода ``table()``:: - - table('products', ['id' => false, 'primary_key' => ['id']]); - $table - ->addColumn('id', 'uuid') - ->addColumn('name', 'string') - ->addColumn('description', 'text') - ->create(); - } - } - -Вышеупомянутый элемент создаст столбец ``id`` с типом ``CHAR(36)``, который также является первичным ключом. - -.. note:: - - При указании настраиваемого первичного ключа в командной строке вы - должны отметить его как первичный ключ в поле id, иначе вы можете - получить ошибку в отношении повторяющихся полей id, т.е.:: - - $ bin/cake bake migration CreateProducts id:uuid:primary name:string description:text created modified - -Кроме того, начиная с Migrations 1.3 был введён новый способ обработки -первичного ключа. Для этого ваш класс миграции должен расширить новый -класс ``Migrations\AbstractMigration``. - -Вы можете указать свойство ``autoId`` в классе Migration и установить его в -``false``, что отключит автоматическое создание столбца ``id``. Вам нужно -будет вручную создать столбец, который будет использоваться в качестве -первичного ключа, и добавить его в объявление таблицы:: - - table('products'); - $table - ->addColumn('id', 'integer', [ - 'autoIncrement' => true, - 'limit' => 11 - ]) - ->addPrimaryKey('id') - ->addColumn('name', 'string') - ->addColumn('description', 'text') - ->create(); - } - } - -По сравнению с предыдущим способом работы с первичным ключом, этот метод даёт -вам возможность больше контролировать определение столбца первичного ключа: -unsigned или not, limit, comment и т.д. - -Все запечённые миграции и моментальные снимки будут использовать этот новый -способ, когда это необходимо. - -.. warning:: - - Работа с первичным ключом может выполняться только при выполнении операций - создания таблиц. Это связано с ограничениями для некоторых серверов баз данных, - поддерживаемых плагинами. - -Параметры сортировки --------------------- - -Если вам нужно создать таблицу с другой сортировкой, чем стандартная по -умолчанию, вы можете определить её с помощью метода ``table()`` в качестве -опции:: - - table('categories', [ - 'collation' => 'latin1_german1_ci' - ]) - ->addColumn('title', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]) - ->create(); - } - } - - -Обратите внимание, что это можно сделать только при создании таблицы: -в настоящее время нет способа добавить столбец в существующую таблицу с -другой сортировкой, чем таблица или база данных. -В настоящее время только ``MySQL`` и ``SqlServer`` поддерживают этот -ключ конфигурации. - -Обновление имени столбцов и использование объектов Table --------------------------------------------------------- - -Если вы используете объект CakePHP ORM Table для управления значениями из -своей базы данных вместе с переименованием или удалением столбца, убедитесь, -что вы создали новый экземпляр объекта Table после вызова ``update()``. -Реестр объектов таблицы очищается после вызова ``update()``, чтобы обновить -схему, которая отражается и хранится в объекте Table при создании экземпляра. - -Миграции и развёртывание ------------------------- - -Если вы используете плагин при развёртывании приложения, обязательно очистите -кэш ORM, чтобы он обновил метаданные столбца ваших таблиц. В противном случае -вы можете столкнуться с ошибками в отношении столбцов, которые не существуют -при выполнении операций над этими новыми столбцами. -Ядро CakePHP включает `Schema Cache Shell -`__ -который вы можете использовать для выполнения этой операции:: - - $ bin/cake schema_cache clear - -Обязательно прочитайте раздел `Schema Cache Shell -`__, -если вы хотите узнать больше об этой оболочке. - -Переименование таблицы ----------------------- - -Плагин даёт вам возможность переименовать таблицу, используя метод ``rename()``. -В файле миграции вы можете сделать следующее:: - - public function up(): void - { - $this->table('old_table_name') - ->rename('new_table_name'); - } - -Пропуск генерации файла ``schema.lock`` ---------------------------------------- - -.. versionadded:: cakephp/migrations 1.6.5 - -Для того, чтобы функция diff работала, каждый раз, когда вы переносите, -откатываете или выпекаете снимок, создается файл **.Lock**, чтобы отслеживать -состояние вашей схемы базы данных в любой момент времени. Вы можете пропустить -создание этого файла, например, при развёртывании в рабочей среде, используя -опцию ``--no-lock`` для вышеупомянутой команды:: - - $ bin/cake migrations migrate --no-lock - - $ bin/cake migrations rollback --no-lock - - $ bin/cake bake migration_snapshot MyMigration --no-lock - diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3e504d8b8..2cda39ed2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,23 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Call to an undefined method object\:\:loadHelper\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Command/BakeMigrationCommand.php - - - - message: '#^Call to an undefined method object\:\:loadHelper\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Command/BakeMigrationDiffCommand.php - - - - message: '#^Call to an undefined method object\:\:loadHelper\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Command/BakeMigrationSnapshotCommand.php - - message: '#^PHPDoc tag @var with type string is not subtype of native type non\-falsy\-string\|true\.$#' identifier: varTag.nativeType @@ -30,30 +12,6 @@ parameters: count: 1 path: src/Command/BakeSeedCommand.php - - - message: '#^Call to an undefined method Cake\\Datasource\\ConnectionInterface\:\:cacheMetadata\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Command/Phinx/CacheBuild.php - - - - message: '#^Call to an undefined method Cake\\Datasource\\ConnectionInterface\:\:cacheMetadata\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Command/Phinx/CacheClear.php - - - - message: '#^Instanceof between Migrations\\CakeManager and Migrations\\CakeManager will always evaluate to true\.$#' - identifier: instanceof.alwaysTrue - count: 1 - path: src/Command/Phinx/MarkMigrated.php - - - - message: '#^Instanceof between Migrations\\CakeManager and Migrations\\CakeManager will always evaluate to true\.$#' - identifier: instanceof.alwaysTrue - count: 1 - path: src/Command/Phinx/Status.php - - message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(int\|string\)\: mixed\)\|null, Closure\(string\)\: string given\.$#' identifier: argument.type @@ -72,84 +30,12 @@ parameters: count: 2 path: src/Db/Adapter/MysqlAdapter.php - - - message: '#^Right side of && is always true\.$#' - identifier: booleanAnd.rightAlwaysTrue - count: 1 - path: src/Db/Adapter/MysqlAdapter.php - - - - message: '#^Parameter \#1 \$columns of method Migrations\\Db\\Table\\Index\:\:setColumns\(\) expects array\\|string, array\|int\|string given\.$#' - identifier: argument.type - count: 1 - path: src/Db/Adapter/PhinxAdapter.php - - - - message: '#^Parameter \#1 \$constraint of method Migrations\\Db\\Table\\ForeignKey\:\:setConstraint\(\) expects string, array\\|string given\.$#' - identifier: argument.type - count: 1 - path: src/Db/Adapter/PhinxAdapter.php - - - - message: '#^Parameter \#1 \$includedColumns of method Migrations\\Db\\Table\\Index\:\:setInclude\(\) expects array\, array\|int\|string given\.$#' - identifier: argument.type - count: 1 - path: src/Db/Adapter/PhinxAdapter.php - - - - message: '#^Parameter \#1 \$limit of method Migrations\\Db\\Table\\Index\:\:setLimit\(\) expects array\|int, array\|int\|string given\.$#' - identifier: argument.type - count: 1 - path: src/Db/Adapter/PhinxAdapter.php - - - - message: '#^Parameter \#1 \$name of method Migrations\\Db\\Table\\Index\:\:setName\(\) expects string, array\|int\|string given\.$#' - identifier: argument.type - count: 1 - path: src/Db/Adapter/PhinxAdapter.php - - - - message: '#^Parameter \#1 \$onDelete of method Migrations\\Db\\Table\\ForeignKey\:\:setOnDelete\(\) expects string, array\\|string given\.$#' - identifier: argument.type - count: 1 - path: src/Db/Adapter/PhinxAdapter.php - - - - message: '#^Parameter \#1 \$onUpdate of method Migrations\\Db\\Table\\ForeignKey\:\:setOnUpdate\(\) expects string, array\\|string given\.$#' - identifier: argument.type - count: 1 - path: src/Db/Adapter/PhinxAdapter.php - - - - message: '#^Parameter \#1 \$order of method Migrations\\Db\\Table\\Index\:\:setOrder\(\) expects array\, array\|int\|string given\.$#' - identifier: argument.type - count: 1 - path: src/Db/Adapter/PhinxAdapter.php - - - - message: '#^Parameter \#1 \$type of method Migrations\\Db\\Table\\Index\:\:setType\(\) expects string, array\|int\|string given\.$#' - identifier: argument.type - count: 1 - path: src/Db/Adapter/PhinxAdapter.php - - - - message: '#^Right side of && is always true\.$#' - identifier: booleanAnd.rightAlwaysTrue - count: 1 - path: src/Db/Adapter/SqliteAdapter.php - - message: '#^Strict comparison using \!\=\= between Cake\\Database\\StatementInterface and null will always evaluate to true\.$#' identifier: notIdentical.alwaysTrue count: 1 path: src/Db/Adapter/SqliteAdapter.php - - - message: '#^PHPDoc tag @return with type Phinx\\Db\\Adapter\\AdapterInterface is not subtype of native type Migrations\\Db\\Adapter\\AdapterInterface\.$#' - identifier: return.phpDocType - count: 1 - path: src/Db/Adapter/SqlserverAdapter.php - - message: '#^Call to an undefined method Migrations\\Db\\Table\\Index\:\:setUnique\(\)\.$#' identifier: method.notFound diff --git a/phpstan.neon b/phpstan.neon index cd57a9b26..d3803bdec 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,4 +10,3 @@ parameters: ignoreErrors: - identifier: missingType.iterableValue - identifier: missingType.generics - diff --git a/src/AbstractMigration.php b/src/AbstractMigration.php deleted file mode 100644 index 61a85922b..000000000 --- a/src/AbstractMigration.php +++ /dev/null @@ -1,68 +0,0 @@ -getAdapter()->hasTransactions(); - } - - /** - * 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\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; - } -} diff --git a/src/AbstractSeed.php b/src/AbstractSeed.php deleted file mode 100644 index 2e51b926c..000000000 --- a/src/AbstractSeed.php +++ /dev/null @@ -1,130 +0,0 @@ -getOutput()->writeln(''); - $this->getOutput()->writeln( - ' ====' . - ' ' . $seeder . ':' . - ' seeding', - ); - - $start = microtime(true); - $this->runCall($seeder); - $end = microtime(true); - - $this->getOutput()->writeln( - ' ====' . - ' ' . $seeder . ':' . - ' seeded' . - ' ' . sprintf('%.4fs', $end - $start) . '', - ); - $this->getOutput()->writeln(''); - } - - /** - * Calls another seeder from this seeder. - * It will load the Seed class you are calling and run it. - * - * @param string $seeder Name of the seeder to call from the current seed - * @return void - */ - protected function runCall(string $seeder): void - { - [$pluginName, $seeder] = pluginSplit($seeder); - - $argv = [ - 'seed', - '--seed', - $seeder, - ]; - - $plugin = $pluginName ?: $this->input->getOption('plugin'); - if ($plugin !== null) { - $argv[] = '--plugin'; - $argv[] = $plugin; - } - - $connection = $this->input->getOption('connection'); - if ($connection !== null) { - $argv[] = '--connection'; - $argv[] = $connection; - } - - $source = $this->input->getOption('source'); - if ($source !== null) { - $argv[] = '--source'; - $argv[] = $source; - } - - $seedCommand = new Seed(); - $input = new ArgvInput($argv, $seedCommand->getDefinition()); - $seedCommand->setInput($input); - $config = $seedCommand->getConfig(); - - $seedPaths = $config->getSeedPaths(); - require_once array_pop($seedPaths) . DS . $seeder . '.php'; - /** @var \Phinx\Seed\SeedInterface $seeder */ - $seeder = new $seeder(); - $seeder->setOutput($this->getOutput()); - $seeder->setAdapter($this->getAdapter()); - $seeder->setInput($this->input); - $seeder->run(); - } - - /** - * Sets the InputInterface this Seed class is being used with. - * - * @param \Symfony\Component\Console\Input\InputInterface $input Input object. - * @return $this - */ - public function setInput(InputInterface $input) - { - $this->input = $input; - - return $this; - } -} diff --git a/src/BaseMigration.php b/src/BaseMigration.php index 9172b1efe..2969933aa 100644 --- a/src/BaseMigration.php +++ b/src/BaseMigration.php @@ -85,12 +85,14 @@ class BaseMigration implements MigrationInterface /** * Constructor * - * @param int $version The version this migration is + * @param int|null $version The version this migration is (null for anonymous migrations) */ - public function __construct(int $version) + public function __construct(?int $version = null) { - $this->validateVersion($version); - $this->version = $version; + if ($version !== null) { + $this->validateVersion($version); + $this->version = $version; + } } /** @@ -429,7 +431,7 @@ public function table(string $tableName, array $options = []): Table /** * Create a new ForeignKey object. * - * @params string|string[] $columns Columns + * @param string|string[] $columns Columns * @return \Migrations\Db\Table\ForeignKey */ public function foreignKey(string|array $columns): ForeignKey @@ -440,7 +442,7 @@ public function foreignKey(string|array $columns): ForeignKey /** * Create a new Index object. * - * @params string|string[] $columns Columns + * @param string|string[] $columns Columns * @return \Migrations\Db\Table\Index */ public function index(string|array $columns): Index diff --git a/src/BaseSeed.php b/src/BaseSeed.php index 8d115ef2f..146abc4e1 100644 --- a/src/BaseSeed.php +++ b/src/BaseSeed.php @@ -129,7 +129,14 @@ public function setConfig(ConfigInterface $config) */ public function getName(): string { - return static::class; + $name = static::class; + if (str_starts_with($name, 'Migrations\BaseSeed@anonymous')) { + if (preg_match('#[/\\\\]([a-zA-Z0-9_]+)\.php:#', $name, $matches)) { + $name = $matches[1]; + } + } + + return $name; } /** @@ -174,6 +181,25 @@ public function insert(string $tableName, array $data): void $table->insert($data)->save(); } + /** + * {@inheritDoc} + */ + public function insertOrSkip(string $tableName, array $data): void + { + // convert to table object + $table = new Table($tableName, [], $this->getAdapter()); + $table->insertOrSkip($data)->save(); + } + + /** + * {@inheritDoc} + */ + public function insertOrUpdate(string $tableName, array $data, array $updateColumns, array $conflictColumns): void + { + $table = new Table($tableName, [], $this->getAdapter()); + $table->insertOrUpdate($data, $updateColumns, $conflictColumns)->save(); + } + /** * {@inheritDoc} */ @@ -198,6 +224,14 @@ public function shouldExecute(): bool return true; } + /** + * {@inheritDoc} + */ + public function isIdempotent(): bool + { + return false; + } + /** * {@inheritDoc} */ @@ -240,15 +274,21 @@ protected function runCall(string $seeder, array $options = []): void [$pluginName, $seeder] = pluginSplit($seeder); $adapter = $this->getAdapter(); $connection = $adapter->getConnection()->configName(); + $config = $this->getConfig(); + $options += [ + 'connection' => $connection, + 'plugin' => $pluginName ?? $config['plugin'], + 'source' => $config['source'], + ]; $factory = new ManagerFactory([ - 'plugin' => $options['plugin'] ?? $pluginName ?? null, - 'source' => $options['source'] ?? null, - 'connection' => $options['connection'] ?? $connection, + 'connection' => $options['connection'], + 'plugin' => $options['plugin'], + 'source' => $options['source'], ]); $io = $this->getIo(); if ($io === null) { - throw new RuntimeException('ConsoleIo is required for running seeders.'); + throw new RuntimeException('ConsoleIo is required for calling other seeders.'); } $manager = $factory->createManager($io); $manager->seed($seeder); diff --git a/src/CakeAdapter.php b/src/CakeAdapter.php deleted file mode 100644 index 23fba255e..000000000 --- a/src/CakeAdapter.php +++ /dev/null @@ -1,111 +0,0 @@ -connection = $connection; - $pdo = $adapter->getConnection(); - - if ($pdo->getAttribute(PDO::ATTR_ERRMODE) !== PDO::ERRMODE_EXCEPTION) { - $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - } - $connection->cacheMetadata(false); - - if ($connection->getDriver() instanceof Postgres) { - $config = $connection->config(); - if (!empty($config['schema'])) { - $pdo->exec('SET search_path TO ' . $pdo->quote($config['schema'])); - } - } - - $driver = $connection->getDriver(); - $prop = new ReflectionProperty($driver, 'pdo'); - $prop->setValue($driver, $pdo); - } - - /** - * Gets the CakePHP Connection object. - * - * @return \Cake\Database\Connection - */ - public function getCakeConnection(): Connection - { - return $this->connection; - } - - /** - * Returns a new Query object - * - * @param string $type The type of query to generate - * (one of the `\Cake\Database\Query::TYPE_*` constants). - * @return \Cake\Database\Query - */ - public function getQueryBuilder(string $type): Query - { - return match ($type) { - Query::TYPE_SELECT => $this->getCakeConnection()->selectQuery(), - Query::TYPE_INSERT => $this->getCakeConnection()->insertQuery(), - Query::TYPE_UPDATE => $this->getCakeConnection()->updateQuery(), - Query::TYPE_DELETE => $this->getCakeConnection()->deleteQuery(), - default => throw new InvalidArgumentException( - 'Query type must be one of: `select`, `insert`, `update`, `delete`.', - ) - }; - } - - /** - * Returns the adapter type name, for example mysql - * - * @return string - */ - public function getAdapterType(): string - { - return $this->getAdapter()->getAdapterType(); - } -} diff --git a/src/CakeManager.php b/src/CakeManager.php deleted file mode 100644 index b28096ecb..000000000 --- a/src/CakeManager.php +++ /dev/null @@ -1,389 +0,0 @@ -migrations = null; - } - - /** - * Reset the seeds stored in the object - * - * @return void - */ - public function resetSeeds(): void - { - $this->seeds = null; - } - - /** - * Prints the specified environment's migration status. - * - * @param string $environment Environment name. - * @param null|string $format Format (`json` or `array`). - * @return array Array of migrations. - */ - public function printStatus(string $environment, ?string $format = null): array - { - $migrations = []; - $isJson = $format === 'json'; - $defaultMigrations = $this->getMigrations('default'); - if ($defaultMigrations) { - $env = $this->getEnvironment($environment); - $versions = $env->getVersionLog(); - $this->maxNameLength = $versions ? max(array_map(function ($version) { - return strlen((string)$version['migration_name']); - }, $versions)) : 0; - - foreach ($defaultMigrations as $migration) { - if (array_key_exists($migration->getVersion(), $versions)) { - $status = 'up'; - unset($versions[$migration->getVersion()]); - } else { - $status = 'down'; - } - - $version = $migration->getVersion(); - $migrationParams = [ - 'status' => $status, - 'id' => $migration->getVersion(), - 'name' => $migration->getName(), - ]; - - $migrations[$version] = $migrationParams; - } - - foreach ($versions as $missing) { - $version = $missing['version']; - $migrationParams = [ - 'status' => 'up', - 'id' => $version, - 'name' => $missing['migration_name'], - ]; - - if (!$isJson) { - $migrationParams = [ - 'missing' => true, - ] + $migrationParams; - } - - $migrations[$version] = $migrationParams; - } - } - - ksort($migrations); - $migrations = array_values($migrations); - - return $migrations; - } - - /** - * @param string $environment Environment - * @param \DateTime $dateTime Date to migrate to - * @param bool $fake flag that if true, we just record running the migration, but not actually do the - * migration - * @return void - */ - public function migrateToDateTime(string $environment, DateTime $dateTime, bool $fake = false): void - { - /** @var array $versions */ - $versions = array_keys($this->getMigrations('default')); - $dateString = $dateTime->format('Ymdhis'); - $versionToMigrate = null; - foreach ($versions as $version) { - if ($dateString > $version) { - $versionToMigrate = $version; - } - } - - if ($versionToMigrate === null) { - $this->getOutput()->writeln( - 'No migrations to run', - ); - - return; - } - - $this->getOutput()->writeln( - 'Migrating to version ' . $versionToMigrate, - ); - $this->migrate($environment, $versionToMigrate, $fake); - } - - /** - * @inheritDoc - */ - public function rollbackToDateTime(string $environment, DateTime $dateTime, bool $force = false): void - { - $env = $this->getEnvironment($environment); - $versions = $env->getVersions(); - $dateString = $dateTime->format('Ymdhis'); - sort($versions); - $versions = array_reverse($versions); - - if (!$versions || $dateString > $versions[0]) { - $this->getOutput()->writeln('No migrations to rollback'); - - return; - } - - if ($dateString < end($versions)) { - $this->getOutput()->writeln('Rolling back all migrations'); - $this->rollback($environment, 0); - - return; - } - - $index = 0; - foreach ($versions as $index => $version) { - if ($dateString > $version) { - break; - } - } - - $versionToRollback = $versions[$index]; - - $this->getOutput()->writeln('Rolling back to version ' . $versionToRollback); - $this->rollback($environment, $versionToRollback, $force); - } - - /** - * Checks if the migration with version number $version as already been mark migrated - * - * @param int $version Version number of the migration to check - * @return bool - */ - public function isMigrated(int $version): bool - { - $adapter = $this->getEnvironment('default')->getAdapter(); - /** @var array $versions */ - $versions = array_flip($adapter->getVersions()); - - return isset($versions[$version]); - } - - /** - * Marks migration with version number $version migrated - * - * @param int $version Version number of the migration to check - * @param string $path Path where the migration file is located - * @return bool True if success - */ - public function markMigrated(int $version, string $path): bool - { - $adapter = $this->getEnvironment('default')->getAdapter(); - - $migrationFile = glob($path . DS . $version . '*'); - - if (!$migrationFile) { - throw new RuntimeException( - sprintf('A migration file matching version number `%s` could not be found', $version), - ); - } - - $migrationFile = $migrationFile[0]; - /** @var class-string<\Phinx\Migration\MigrationInterface> $className */ - $className = $this->getMigrationClassName($migrationFile); - require_once $migrationFile; - $Migration = new $className('default', $version); - - $time = date('Y-m-d H:i:s', time()); - - $adapter->migrated($Migration, 'up', $time, $time); - - return true; - } - - /** - * Decides which versions it should mark as migrated - * - * @param \Symfony\Component\Console\Input\InputInterface $input Input interface from which argument and options - * will be extracted to determine which versions to be marked as migrated - * @return array Array of versions that should be marked as migrated - * @throws \InvalidArgumentException If the `--exclude` or `--only` options are used without `--target` - * or version not found - */ - public function getVersionsToMark(InputInterface $input): array - { - $migrations = $this->getMigrations('default'); - $versions = array_keys($migrations); - - $versionArg = $input->getArgument('version'); - $targetArg = $input->getOption('target'); - $hasAllVersion = in_array($versionArg, ['all', '*'], true); - if ((!$versionArg && !$targetArg) || $hasAllVersion) { - return $versions; - } - - $version = (int)$targetArg ?: (int)$versionArg; - - if ($input->getOption('only') || $versionArg) { - if (!in_array($version, $versions)) { - throw new InvalidArgumentException("Migration `$version` was not found !"); - } - - return [$version]; - } - - $lengthIncrease = $input->getOption('exclude') ? 0 : 1; - $index = array_search($version, $versions); - - if ($index === false) { - throw new InvalidArgumentException("Migration `$version` was not found !"); - } - - return array_slice($versions, 0, $index + $lengthIncrease); - } - - /** - * Mark all migrations in $versions array found in $path as migrated - * - * It will start a transaction and rollback in case one of the operation raises an exception - * - * @param string $path Path where to look for migrations - * @param array $versions Versions which should be marked - * @param \Symfony\Component\Console\Output\OutputInterface $output OutputInterface used to store - * the command output - * @return void - */ - public function markVersionsAsMigrated(string $path, array $versions, OutputInterface $output): void - { - $adapter = $this->getEnvironment('default')->getAdapter(); - - if (!$versions) { - $output->writeln('No migrations were found. Nothing to mark as migrated.'); - - return; - } - - $adapter->beginTransaction(); - foreach ($versions as $version) { - if ($this->isMigrated($version)) { - $output->writeln(sprintf('Skipping migration `%s` (already migrated).', $version)); - continue; - } - - try { - $this->markMigrated($version, $path); - $output->writeln( - sprintf('Migration `%s` successfully marked migrated !', $version), - ); - } catch (Exception $e) { - $adapter->rollbackTransaction(); - $output->writeln( - sprintf( - 'An error occurred while marking migration `%s` as migrated : %s', - $version, - $e->getMessage(), - ), - ); - $output->writeln('All marked migrations during this process were unmarked.'); - - return; - } - } - $adapter->commitTransaction(); - } - - /** - * Resolves a migration class name based on $path - * - * @param string $path Path to the migration file of which we want the class name - * @return string Migration class name - */ - protected function getMigrationClassName(string $path): string - { - $class = (string)preg_replace('/^[0-9]+_/', '', basename($path)); - $class = str_replace('_', ' ', $class); - $class = ucwords($class); - $class = str_replace(' ', '', $class); - if (strpos($class, '.') !== false) { - $class = substr($class, 0, strpos($class, '.')); - } - - return $class; - } - - /** - * Sets the InputInterface the Manager is dealing with for the current shell call - * - * @param \Symfony\Component\Console\Input\InputInterface $input Instance of InputInterface - * @return $this - */ - public function setInput(InputInterface $input) - { - $this->input = $input; - - return $this; - } - - /** - * Gets an array of database seeders. - * - * Overload the basic behavior to add an instance of the InputInterface the shell call is - * using in order to give the ability to the AbstractSeed::call() method to propagate options - * to the other MigrationsDispatcher it is generating. - * - * @throws \InvalidArgumentException - * @param string $environment Environment. - * @return \Phinx\Seed\SeedInterface[] - */ - public function getSeeds(string $environment): array - { - parent::getSeeds($environment); - if (!$this->seeds) { - return []; - } - - foreach ($this->seeds as $instance) { - if ($instance instanceof AbstractSeed) { - $instance->setInput($this->input); - } - } - - return $this->seeds; - } -} diff --git a/src/Command/BakeMigrationCommand.php b/src/Command/BakeMigrationCommand.php index 14fbf7781..c727b03ee 100644 --- a/src/Command/BakeMigrationCommand.php +++ b/src/Command/BakeMigrationCommand.php @@ -48,7 +48,9 @@ public static function defaultName(): string public function bake(string $name, Arguments $args, ConsoleIo $io): void { EventManager::instance()->on('Bake.initialize', function (Event $event): void { - $event->getSubject()->loadHelper('Migrations.Migration'); + /** @var \Bake\View\BakeView $view */ + $view = $event->getSubject(); + $view->loadHelper('Migrations.Migration'); }); $this->_name = $name; @@ -60,6 +62,11 @@ public function bake(string $name, Arguments $args, ConsoleIo $io): void */ public function template(): string { + $style = $this->args->getOption('style') ?? Configure::read('Migrations.style', 'traditional'); + if ($style === 'anonymous') { + return 'Migrations.config/skeleton-anonymous'; + } + return 'Migrations.config/skeleton'; } @@ -88,7 +95,7 @@ public function templateData(Arguments $arguments): array $action = $this->detectAction($className); if (!$action && count($fields)) { - $this->io->abort('When applying fields the migration name should start with one of the following prefixes: `Create`, `Drop`, `Add`, `Remove`, `Alter`. See: https://book.cakephp.org/migrations/4/en/index.html#migrations-file-name'); + $this->io->abort('When applying fields the migration name should start with one of the following prefixes: `Create`, `Drop`, `Add`, `Remove`, `Alter`. See: https://book.cakephp.org/migrations/5/en/index.html#migrations-file-name'); } if (!$action) { @@ -99,7 +106,6 @@ public function templateData(Arguments $arguments): array 'tables' => [], 'action' => null, 'name' => $className, - 'backend' => Configure::read('Migrations.backend', 'builtin'), ]; } @@ -122,7 +128,6 @@ public function templateData(Arguments $arguments): array ], 'constraints' => $foreignKeys, 'name' => $className, - 'backend' => Configure::read('Migrations.backend', 'builtin'), ]; } @@ -171,7 +176,7 @@ public function getOptionParser(): ConsoleOptionParser When describing columns you can use the following syntax: -{name}:{primary}{type}{nullable}[{length}]:{index}:{indexName} +{name}:{type}{nullable}[{length}]:default[{value}]:{index}:{indexName} All sections other than name are optional. @@ -179,6 +184,9 @@ public function getOptionParser(): ConsoleOptionParser * The ? value indicates if a column is nullable. e.g. role:string?. * Length option must be enclosed in [], for example: name:string?[100]. +* The default[value] option sets a default value for the column. + Supports booleans (true/false), integers, floats, strings, and null. + e.g. active:boolean:default[true], count:integer:default[0]. * The index attribute can define the column as having a unique key with unique or a primary key with primary. * Use references type to create a foreign key constraint. @@ -211,6 +219,25 @@ public function getOptionParser(): ConsoleOptionParser Create a migration that adds a foreign key column (category_id) to the articles table referencing the categories table. +bin/cake bake migration AddActiveToUsers active:boolean:default[true] +Create a migration that adds an active column with a default value of true. + +bin/cake bake migration AddCountToProducts count:integer:default[0]:unique +Create a migration that adds a count column with default 0 and a unique index. + +Migration Styles + +You can generate migrations in different styles: + +bin/cake bake migration --style=anonymous CreatePosts +Creates an anonymous class migration with readable file naming (2024_12_08_120000_CreatePosts.php) + +bin/cake bake migration --style=traditional CreatePosts +Creates a traditional class-based migration (20241208120000_create_posts.php) + +You can set the default style in your configuration: +Configure::write('Migrations.style', 'anonymous'); + TEXT; $parser->setDescription($text); @@ -218,6 +245,22 @@ public function getOptionParser(): ConsoleOptionParser return $parser; } + /** + * @inheritDoc + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser = parent::buildOptionParser($parser); + + $parser->addOption('style', [ + 'help' => 'Migration style to use (traditional or anonymous).', + 'default' => null, + 'choices' => ['traditional', 'anonymous'], + ]); + + return $parser; + } + /** * Detects the action and table from the name of a migration * diff --git a/src/Command/BakeMigrationDiffCommand.php b/src/Command/BakeMigrationDiffCommand.php index e052899b1..68bcd71bc 100644 --- a/src/Command/BakeMigrationDiffCommand.php +++ b/src/Command/BakeMigrationDiffCommand.php @@ -18,17 +18,22 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; -use Cake\Core\Configure; use Cake\Database\Connection; +use Cake\Database\Schema\CachedCollection; +use Cake\Database\Schema\CheckConstraint; use Cake\Database\Schema\CollectionInterface; +use Cake\Database\Schema\Column; +use Cake\Database\Schema\Constraint; +use Cake\Database\Schema\ForeignKey; +use Cake\Database\Schema\Index; use Cake\Database\Schema\TableSchema; +use Cake\Database\Schema\UniqueKey; use Cake\Datasource\ConnectionManager; use Cake\Event\Event; use Cake\Event\EventManager; -use Migrations\Command\Phinx\Dump; +use Migrations\Migration\ManagerFactory; use Migrations\Util\TableFinder; use Migrations\Util\UtilTrait; -use Symfony\Component\Console\Input\ArrayInput; /** * Task class for generating migration diff files. @@ -130,7 +135,9 @@ public function bake(string $name, Arguments $args, ConsoleIo $io): void assert($connection instanceof Connection); EventManager::instance()->on('Bake.initialize', function (Event $event) use ($collection, $connection): void { - $event->getSubject()->loadHelper('Migrations.Migration', [ + /** @var \Bake\View\BakeView $view */ + $view = $event->getSubject(); + $view->loadHelper('Migrations.Migration', [ 'collection' => $collection, 'connection' => $connection, ]); @@ -199,7 +206,6 @@ public function templateData(Arguments $arguments): array 'data' => $this->templateData, 'dumpSchema' => $this->dumpSchema, 'currentSchema' => $this->currentSchema, - 'backend' => Configure::read('Migrations.backend', 'builtin'), ]; } @@ -293,21 +299,44 @@ protected function getColumns(): void } } + // Only convert unsigned to signed if it actually changed if (isset($changedAttributes['unsigned'])) { $changedAttributes['signed'] = !$changedAttributes['unsigned']; unset($changedAttributes['unsigned']); - } else { - // badish hack - if (isset($column['unsigned']) && $column['unsigned'] === true) { - $changedAttributes['signed'] = false; - } } - if (isset($changedAttributes['length'])) { + // For decimal columns, handle CakePHP schema -> migration attribute mapping + if ($column['type'] === 'decimal') { + // In CakePHP schema: 'length' = precision, 'precision' = scale + // In migrations: 'precision' = precision, 'scale' = scale + + // Convert CakePHP schema's 'precision' (which is scale) to migration's 'scale' + if (isset($changedAttributes['precision'])) { + $changedAttributes['scale'] = $changedAttributes['precision']; + unset($changedAttributes['precision']); + } + + // Convert CakePHP schema's 'length' (which is precision) to migration's 'precision' + if (isset($changedAttributes['length'])) { + $changedAttributes['precision'] = $changedAttributes['length']; + unset($changedAttributes['length']); + } + + // Ensure both precision and scale are always set for decimal columns + if (!isset($changedAttributes['precision']) && isset($column['length'])) { + $changedAttributes['precision'] = $column['length']; + } + if (!isset($changedAttributes['scale']) && isset($column['precision'])) { + $changedAttributes['scale'] = $column['precision']; + } + + // Remove 'limit' for decimal columns as they use precision/scale instead + unset($changedAttributes['limit']); + } elseif (isset($changedAttributes['length'])) { + // For non-decimal columns, convert 'length' to 'limit' if (!isset($changedAttributes['limit'])) { $changedAttributes['limit'] = $changedAttributes['length']; } - unset($changedAttributes['length']); } @@ -463,7 +492,7 @@ protected function checkSync(): bool $lastVersion = $this->migratedItems[0]['version']; $lastFile = end($this->migrationsFiles); - return $lastFile && (bool)strpos($lastFile, (string)$lastVersion); + return $lastFile && str_contains($lastFile, (string)$lastVersion); } return false; @@ -486,8 +515,6 @@ protected function bakeSnapshot(string $name, Arguments $args, ConsoleIo $io): ? $newArgs[] = $name; $newArgs = array_merge($newArgs, $this->parseOptions($args)); - - // TODO(mark) This nested command call always uses phinx backend. $exitCode = $this->executeCommand(BakeMigrationSnapshotCommand::class, $newArgs, $io); if ($exitCode === 1) { @@ -506,35 +533,41 @@ protected function bakeSnapshot(string $name, Arguments $args, ConsoleIo $io): ? */ protected function getDumpSchema(Arguments $args): array { - $inputArgs = []; + $options = []; $connectionName = 'default'; if ($args->getOption('connection')) { $connectionName = $inputArgs['--connection'] = $args->getOption('connection'); } + $options['connection'] = $connectionName; + $options['source'] = $args->getOption('source'); + $options['plugin'] = $args->getOption('plugin'); - if ($args->getOption('source')) { - $inputArgs['--source'] = $args->getOption('source'); - } - - if ($args->getOption('plugin')) { - $inputArgs['--plugin'] = $args->getOption('plugin'); - } - - // TODO(mark) This has to change for the built-in backend - $className = Dump::class; - $definition = (new $className())->getDefinition(); - - $input = new ArrayInput($inputArgs, $definition); - $path = $this->getOperationsPath($input) . DS . 'schema-dump-' . $connectionName . '.lock'; + $factory = new ManagerFactory($options); + $config = $factory->createConfig(); + $path = $config->getMigrationPath() . DS . 'schema-dump-' . $connectionName . '.lock'; if (!file_exists($path)) { $msg = 'Unable to retrieve the schema dump file. You can create a dump file using ' . 'the `cake migrations dump` command'; $this->io->abort($msg); } - return unserialize((string)file_get_contents($path)); + $contents = (string)file_get_contents($path); + + // Use allowed_classes to restrict deserialization to safe CakePHP schema classes + return unserialize($contents, [ + 'allowed_classes' => [ + TableSchema::class, + CachedCollection::class, + Column::class, + Index::class, + Constraint::class, + UniqueKey::class, + ForeignKey::class, + CheckConstraint::class, + ], + ]); } /** @@ -570,6 +603,9 @@ protected function getCurrentSchema(): array if (preg_match('/^.*phinxlog$/', $table) === 1) { continue; } + if ($table === 'cake_migrations' || $table === 'cake_seeds') { + continue; + } $schema[$table] = $collection->describe($table); } diff --git a/src/Command/BakeMigrationSnapshotCommand.php b/src/Command/BakeMigrationSnapshotCommand.php index 7caff7b58..259b62bfd 100644 --- a/src/Command/BakeMigrationSnapshotCommand.php +++ b/src/Command/BakeMigrationSnapshotCommand.php @@ -59,7 +59,9 @@ public function bake(string $name, Arguments $args, ConsoleIo $io): void assert($connection instanceof Connection); EventManager::instance()->on('Bake.initialize', function (Event $event) use ($collection, $connection): void { - $event->getSubject()->loadHelper('Migrations.Migration', [ + /** @var \Bake\View\BakeView $view */ + $view = $event->getSubject(); + $view->loadHelper('Migrations.Migration', [ 'collection' => $collection, 'connection' => $connection, ]); @@ -106,6 +108,8 @@ public function templateData(Arguments $arguments): array $autoId = !$arguments->getOption('disable-autoid'); } + $useChange = (bool)$arguments->getOption('change'); + return [ 'plugin' => $this->plugin, 'pluginPath' => $pluginPath, @@ -115,7 +119,7 @@ public function templateData(Arguments $arguments): array 'action' => 'create_table', 'name' => $this->_name, 'autoId' => $autoId, - 'backend' => Configure::read('Migrations.backend', 'builtin'), + 'useChange' => $useChange, ]; } @@ -180,6 +184,11 @@ public function getOptionParser(): ConsoleOptionParser ->addOption('generate-only', [ 'help' => 'Only generate the migration file without marking it as applied', 'boolean' => true, + ]) + ->addOption('change', [ + 'help' => 'Use change() method instead of up()/down() methods', + 'boolean' => true, + 'default' => false, ]); return $parser; diff --git a/src/Command/BakeSeedCommand.php b/src/Command/BakeSeedCommand.php index dd0700856..8cedabf45 100644 --- a/src/Command/BakeSeedCommand.php +++ b/src/Command/BakeSeedCommand.php @@ -42,6 +42,13 @@ class BakeSeedCommand extends SimpleBakeCommand */ protected string $_name; + /** + * Arguments + * + * @var \Cake\Console\Arguments|null + */ + protected ?Arguments $args = null; + /** * @inheritDoc */ @@ -84,6 +91,11 @@ public function getPath(Arguments $args): string */ public function template(): string { + $style = $this->args?->getOption('style') ?? Configure::read('Migrations.style', 'traditional'); + if ($style === 'anonymous') { + return 'Migrations.Seed/seed-anonymous'; + } + return 'Migrations.Seed/seed'; } @@ -142,7 +154,6 @@ public function templateData(Arguments $arguments): array 'namespace' => $namespace, 'records' => $records, 'table' => $table, - 'backend' => Configure::read('Migrations.backend', 'builtin'), ]; } @@ -151,6 +162,7 @@ public function templateData(Arguments $arguments): array */ public function bake(string $name, Arguments $args, ConsoleIo $io): void { + $this->args = $args; /** @var array $options */ $options = array_merge($args->getOptions(), ['no-test' => true]); $newArgs = new Arguments( @@ -185,6 +197,10 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar ])->addOption('limit', [ 'short' => 'l', 'help' => 'If including data, max number of rows to select', + ])->addOption('style', [ + 'help' => 'Seed style to use (traditional or anonymous).', + 'default' => null, + 'choices' => ['traditional', 'anonymous'], ]); return $parser; diff --git a/src/Command/BakeSimpleMigrationCommand.php b/src/Command/BakeSimpleMigrationCommand.php index 8ed82d765..622b61fcc 100644 --- a/src/Command/BakeSimpleMigrationCommand.php +++ b/src/Command/BakeSimpleMigrationCommand.php @@ -20,8 +20,11 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; +use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\Utility\Inflector; +use DateTime; +use DateTimeZone; use Migrations\Util\Util; /** @@ -74,6 +77,32 @@ public function name(): string public function fileName($name): string { $name = $this->getMigrationName($name); + $style = $this->args->getOption('style') ?? Configure::read('Migrations.style', 'traditional'); + + if ($style === 'anonymous') { + // Use readable format: 2024_12_08_120000_CamelCaseName.php + $timestamp = Util::getCurrentTimestamp(); + $dt = new DateTime(); + $dt->setTimestamp((int)substr($timestamp, 0, 10)); + $dt->setTimezone(new DateTimeZone('UTC')); + + $readableDate = $dt->format('Y_m_d'); + $time = substr($timestamp, 8); + $camelName = Inflector::camelize($name); + + $path = $this->getPath($this->args); + $offset = 0; + while (glob($path . $readableDate . '_' . $time . '_*.php')) { + $timestamp = Util::getCurrentTimestamp(++$offset); + $dt->setTimestamp((int)substr($timestamp, 0, 10)); + $readableDate = $dt->format('Y_m_d'); + $time = substr($timestamp, 8); + } + + return $readableDate . '_' . $time . '_' . $camelName . '.php'; + } + + // Traditional format $timestamp = Util::getCurrentTimestamp(); $suffix = '_' . Inflector::camelize($name) . '.php'; diff --git a/src/Command/EntryCommand.php b/src/Command/EntryCommand.php index fc6648c2a..3651d928d 100644 --- a/src/Command/EntryCommand.php +++ b/src/Command/EntryCommand.php @@ -23,7 +23,6 @@ use Cake\Console\CommandCollectionAwareInterface; use Cake\Console\ConsoleIo; use Cake\Console\Exception\ConsoleException; -use Cake\Core\Configure; /** * Command that provides help and an entry point to migrations tools. @@ -83,14 +82,11 @@ public function run(array $argv, ConsoleIo $io): ?int // This is the variance from Command::run() if (!$args->getArgumentAt(0) && $args->getOption('help')) { - $backend = Configure::read('Migrations.backend', 'builtin'); $io->out([ 'Migrations', '', "Migrations provides commands for managing your application's database schema and initial data.", '', - "Using {$backend} backend.", - '', ]); $help = $this->getHelp(); $this->executeCommand($help, [], $io); diff --git a/src/Command/MigrateCommand.php b/src/Command/MigrateCommand.php index 426645e84..abc73655f 100644 --- a/src/Command/MigrateCommand.php +++ b/src/Command/MigrateCommand.php @@ -19,7 +19,6 @@ use Cake\Console\ConsoleOptionParser; use Cake\Event\EventDispatcherTrait; use DateTime; -use Exception; use LogicException; use Migrations\Config\ConfigInterface; use Migrations\Migration\ManagerFactory; @@ -170,11 +169,6 @@ protected function executeMigrations(Arguments $args, ConsoleIo $io): ?int $manager->migrate($version, $fake, $count); } $end = microtime(true); - } catch (Exception $e) { - $io->err('' . $e->getMessage() . ''); - $io->verbose($e->getTraceAsString()); - - return self::CODE_ERROR; } catch (Throwable $e) { $io->err('' . $e->getMessage() . ''); $io->verbose($e->getTraceAsString()); diff --git a/src/Command/MigrationsCacheBuildCommand.php b/src/Command/MigrationsCacheBuildCommand.php deleted file mode 100644 index 928372220..000000000 --- a/src/Command/MigrationsCacheBuildCommand.php +++ /dev/null @@ -1,29 +0,0 @@ -getName(); - - return 'migrations ' . $name; - } - - /** - * Array of arguments to run the shell with. - * - * @var list - */ - public array $argv = []; - - /** - * Defines what options can be passed to the shell. - * This is required because CakePHP validates the passed options - * and would complain if something not configured here is present - * - * @return \Cake\Console\ConsoleOptionParser - */ - public function getOptionParser(): ConsoleOptionParser - { - if ($this->defaultName() === 'migrations') { - return parent::getOptionParser(); - } - $parser = parent::getOptionParser(); - $className = MigrationsDispatcher::getCommands()[static::$commandName]; - $command = new $className(); - - // Skip conversions for new commands. - $parser->setDescription($command->getDescription()); - $definition = $command->getDefinition(); - foreach ($definition->getOptions() as $option) { - if ($option->getShortcut()) { - $parser->addOption($option->getName(), [ - 'short' => $option->getShortcut(), - 'help' => $option->getDescription(), - ]); - continue; - } - $parser->addOption($option->getName()); - } - - return $parser; - } - - /** - * Defines constants that are required by phinx to get running - * - * @return void - */ - public function initialize(): void - { - if (!defined('PHINX_VERSION')) { - define('PHINX_VERSION', 'UNKNOWN'); - } - parent::initialize(); - } - - /** - * This acts as a front-controller for phinx. It just instantiates the classes - * responsible for parsing the command line from phinx and gives full control of - * the rest of the flow to it. - * - * The input parameter of the ``MigrationDispatcher::run()`` method is manually built - * in case a MigrationsShell is dispatched using ``Shell::dispatch()``. - * - * @param \Cake\Console\Arguments $args The command arguments. - * @param \Cake\Console\ConsoleIo $io The console io - * @return null|int The exit code or null for success - */ - public function execute(Arguments $args, ConsoleIo $io): ?int - { - $app = $this->getApp(); - $input = new ArgvInput($this->argv); - $app->setAutoExit(false); - $exitCode = $app->run($input, $this->getOutput()); - - if (in_array('-h', $this->argv, true) || in_array('--help', $this->argv, true)) { - return $exitCode; - } - - if ( - isset($this->argv[1]) && in_array($this->argv[1], ['migrate', 'rollback'], true) && - !in_array('--no-lock', $this->argv, true) && - $exitCode === 0 - ) { - $newArgs = []; - if ($args->getOption('connection')) { - $newArgs[] = '-c'; - $newArgs[] = $args->getOption('connection'); - } - - if ($args->getOption('plugin')) { - $newArgs[] = '-p'; - $newArgs[] = $args->getOption('plugin'); - } - - $io->out(''); - $io->out('Dumps the current schema of the database to be used while baking a diff'); - $io->out(''); - - $dumpExitCode = $this->executeCommand(MigrationsDumpCommand::class, $newArgs, $io); - } - - if (isset($dumpExitCode) && $exitCode === 0 && $dumpExitCode !== 0) { - $exitCode = 1; - } - - return $exitCode; - } - - /** - * Returns the MigrationsDispatcher the Shell will have to use - * - * @return \Migrations\MigrationsDispatcher - */ - protected function getApp(): MigrationsDispatcher - { - return new MigrationsDispatcher(PHINX_VERSION); - } - - /** - * Returns the instance of OutputInterface the MigrationsDispatcher will have to use. - * - * @return \Symfony\Component\Console\Output\OutputInterface - */ - protected function getOutput(): OutputInterface - { - return new ConsoleOutput(); - } - - /** - * Override the default behavior to save the command called - * in order to pass it to the command dispatcher - * - * @param array $argv Arguments from the CLI environment. - * @param \Cake\Console\ConsoleIo $io The console io - * @return int|null Exit code or null for success. - */ - public function run(array $argv, ConsoleIo $io): ?int - { - $name = static::defaultName(); - $name = explode(' ', $name); - - array_unshift($argv, ...$name); - /** @var list $argv */ - $this->argv = $argv; - - return parent::run($argv, $io); - } - - /** - * Output help content - * - * @param \Cake\Console\ConsoleOptionParser $parser The option parser. - * @param \Cake\Console\Arguments $args The command arguments. - * @param \Cake\Console\ConsoleIo $io The console io - * @return void - */ - protected function displayHelp(ConsoleOptionParser $parser, Arguments $args, ConsoleIo $io): void - { - $this->execute($args, $io); - } -} diff --git a/src/Command/MigrationsCreateCommand.php b/src/Command/MigrationsCreateCommand.php deleted file mode 100644 index bab306ec2..000000000 --- a/src/Command/MigrationsCreateCommand.php +++ /dev/null @@ -1,29 +0,0 @@ -setName('orm-cache-build') - ->setDescription( - 'Build all metadata caches for the connection. ' . - 'If a table name is provided, only that table will be cached.', - ) - ->addOption( - 'connection', - null, - InputOption::VALUE_OPTIONAL, - 'The connection to build/clear metadata cache data for.', - 'default', - ) - ->addArgument( - 'name', - InputArgument::OPTIONAL, - 'A specific table you want to clear/refresh cached data for.', - ); - } - - /** - * @inheritDoc - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { - /** @var string $name */ - $name = $input->getArgument('name'); - $schema = $this->_getSchema($input, $output); - if (!$schema) { - return static::CODE_ERROR; - } - $tables = [$name]; - if (!$name) { - $tables = $schema->listTables(); - } - foreach ($tables as $table) { - $output->writeln('Building metadata cache for ' . $table); - $schema->describe($table, ['forceRefresh' => true]); - } - $output->writeln('Cache build complete'); - - return static::CODE_SUCCESS; - } -} diff --git a/src/Command/Phinx/CacheClear.php b/src/Command/Phinx/CacheClear.php deleted file mode 100644 index 80b7cc35a..000000000 --- a/src/Command/Phinx/CacheClear.php +++ /dev/null @@ -1,70 +0,0 @@ -setName('orm-cache-clear') - ->setDescription( - 'Clear all metadata caches for the connection. ' . - 'If a table name is provided, only that table will be removed.', - ) - ->addOption( - 'connection', - null, - InputOption::VALUE_OPTIONAL, - 'The connection to build/clear metadata cache data for.', - 'default', - ) - ->addArgument( - 'name', - InputArgument::OPTIONAL, - 'A specific table you want to clear/refresh cached data for.', - ); - } - - /** - * @inheritDoc - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { - $schema = $this->_getSchema($input, $output); - /** @var string $name */ - $name = $input->getArgument('name'); - if (!$schema) { - return static::CODE_ERROR; - } - $tables = [$name]; - if (!$name) { - $tables = $schema->listTables(); - } - $cacher = $schema->getCacher(); - foreach ($tables as $table) { - $output->writeln(sprintf( - 'Clearing metadata cache for %s', - $table, - )); - $cacher->delete($table); - } - $output->writeln('Cache clear complete'); - - return static::CODE_SUCCESS; - } -} diff --git a/src/Command/Phinx/CommandTrait.php b/src/Command/Phinx/CommandTrait.php deleted file mode 100644 index 1d564b490..000000000 --- a/src/Command/Phinx/CommandTrait.php +++ /dev/null @@ -1,96 +0,0 @@ -beforeExecute($input, $output); - - return parent::execute($input, $output); - } - - /** - * Overrides the action execute method in order to vanish the idea of environments - * from phinx. CakePHP does not believe in the idea of having in-app environments - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @param \Symfony\Component\Console\Output\OutputInterface $output the output object - * @return void - */ - protected function beforeExecute(InputInterface $input, OutputInterface $output): void - { - $this->setInput($input); - $this->addOption('--environment', '-e', InputArgument::OPTIONAL); - $input->setOption('environment', 'default'); - } - - /** - * A callback method that is used to inject the PDO object created from phinx into - * the CakePHP connection. This is needed in case the user decides to use tables - * from the ORM and executes queries. - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @param \Symfony\Component\Console\Output\OutputInterface $output the output object - * @return void - */ - public function bootstrap(InputInterface $input, OutputInterface $output): void - { - parent::bootstrap($input, $output); - $name = $this->getConnectionName($input); - $this->connection = $name; - ConnectionManager::alias($name, 'default'); - /** @var \Cake\Database\Connection $connection */ - $connection = ConnectionManager::get($name); - - $manager = $this->getManager(); - - if (!$manager instanceof CakeManager) { - $this->setManager(new CakeManager($this->getConfig(), $input, $output)); - } - $env = $this->getManager()->getEnvironment('default'); - $adapter = $env->getAdapter(); - if (!$adapter instanceof CakeAdapter) { - $env->setAdapter(new CakeAdapter($adapter, $connection)); - } - } - - /** - * Sets the input object that should be used for the command class. This object - * is used to inspect the extra options that are needed for CakePHP apps. - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @return void - */ - public function setInput(InputInterface $input): void - { - $this->input = $input; - } -} diff --git a/src/Command/Phinx/Create.php b/src/Command/Phinx/Create.php deleted file mode 100644 index 33018bb5f..000000000 --- a/src/Command/Phinx/Create.php +++ /dev/null @@ -1,126 +0,0 @@ -setName('create') - ->setDescription('Create a new migration') - ->addArgument('name', InputArgument::REQUIRED, 'What is the name of the migration?') - ->setHelp(sprintf( - '%sCreates a new database migration file%s', - PHP_EOL, - PHP_EOL, - )) - ->addOption('plugin', 'p', InputOption::VALUE_REQUIRED, 'The plugin the file should be created for') - ->addOption('connection', 'c', InputOption::VALUE_REQUIRED, 'The datasource connection to use') - ->addOption('source', 's', InputOption::VALUE_REQUIRED, 'The folder where migrations are in') - ->addOption('template', 't', InputOption::VALUE_REQUIRED, 'Use an alternative template') - ->addOption( - 'class', - 'l', - InputOption::VALUE_REQUIRED, - 'Use a class implementing "' . parent::CREATION_INTERFACE . '" to generate the template', - ) - ->addOption( - 'path', - null, - InputOption::VALUE_REQUIRED, - 'Specify the path in which to create this migration', - ); - } - - /** - * Configures Phinx Create command CLI options that are unused by this extended - * command. - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @param \Symfony\Component\Console\Output\OutputInterface $output the output object - * @return void - */ - protected function beforeExecute(InputInterface $input, OutputInterface $output): void - { - // Set up as a dummy, its value is not going to be used, as a custom - // template will always be set. - $this->addOption('style', null, InputOption::VALUE_OPTIONAL); - - $this->parentBeforeExecute($input, $output); - } - - /** - * Overrides the action execute method in order to vanish the idea of environments - * from phinx. CakePHP does not believe in the idea of having in-app environments - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @param \Symfony\Component\Console\Output\OutputInterface $output the output object - * @return int - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { - $result = $this->parentExecute($input, $output); - - $output->writeln('renaming file in CamelCase to follow CakePHP convention...'); - - $migrationPaths = $this->getConfig()->getMigrationPaths(); - $migrationPath = array_pop($migrationPaths) . DS; - /** @var string $name */ - $name = $input->getArgument('name'); - // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable - [$phinxTimestamp, $phinxName] = explode('_', Util::mapClassNameToFileName($name), 2); - $migrationFilename = glob($migrationPath . '*' . $phinxName); - - if (!$migrationFilename) { - $output->writeln('An error occurred while renaming file'); - } else { - $migrationFilename = $migrationFilename[0]; - $path = dirname($migrationFilename) . DS; - $name = Inflector::camelize($name); - $newPath = $path . Util::getCurrentTimestamp() . '_' . $name . '.php'; - - $output->writeln('renaming file in CamelCase to follow CakePHP convention...'); - if (rename($migrationFilename, $newPath)) { - $output->writeln(sprintf('File successfully renamed to %s', $newPath)); - } else { - $output->writeln(sprintf('An error occurred while renaming file to %s', $newPath)); - } - } - - return $result; - } -} diff --git a/src/Command/Phinx/Dump.php b/src/Command/Phinx/Dump.php deleted file mode 100644 index 32541961a..000000000 --- a/src/Command/Phinx/Dump.php +++ /dev/null @@ -1,128 +0,0 @@ -setName('dump') - ->setDescription('Dumps the current schema of the database to be used while baking a diff') - ->setHelp(sprintf( - '%sDumps the current schema of the database to be used while baking a diff%s', - PHP_EOL, - PHP_EOL, - )) - ->addOption('plugin', 'p', InputOption::VALUE_REQUIRED, 'The plugin the file should be created for') - ->addOption('connection', 'c', InputOption::VALUE_REQUIRED, 'The datasource connection to use') - ->addOption('source', 's', InputOption::VALUE_REQUIRED, 'The folder where migrations are in'); - } - - /** - * @param \Symfony\Component\Console\Output\OutputInterface $output The output object. - * @return \Symfony\Component\Console\Output\OutputInterface|null - */ - public function output(?OutputInterface $output = null): ?OutputInterface - { - if ($output !== null) { - $this->output = $output; - } - - return $this->output; - } - - /** - * Dumps the current schema to be used when baking a diff - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @param \Symfony\Component\Console\Output\OutputInterface $output the output object - * @return int - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { - $this->setInput($input); - $this->bootstrap($input, $output); - $this->output($output); - - $path = $this->getOperationsPath($input); - $connectionName = $input->getOption('connection') ?: 'default'; - assert(is_string($connectionName), 'Connection name must be a string'); - $connection = ConnectionManager::get($connectionName); - assert($connection instanceof Connection); - $collection = $connection->getSchemaCollection(); - - $options = [ - 'require-table' => false, - 'plugin' => $this->getPlugin($input), - ]; - $finder = new TableFinder($connectionName); - $tables = $finder->getTablesToBake($collection, $options); - - sort($tables); - - $dump = []; - if ($tables) { - foreach ($tables as $table) { - $schema = $collection->describe($table); - $dump[$table] = $schema; - } - } - - $filePath = $path . DS . 'schema-dump-' . $connectionName . '.lock'; - $output->writeln(sprintf('Writing dump file `%s`...', $filePath)); - if (file_put_contents($filePath, serialize($dump))) { - $output->writeln(sprintf('Dump file `%s` was successfully written', $filePath)); - - return BaseCommand::CODE_SUCCESS; - } - - $output->writeln(sprintf( - 'An error occurred while writing dump file `%s`', - $filePath, - )); - - return BaseCommand::CODE_ERROR; - } -} diff --git a/src/Command/Phinx/MarkMigrated.php b/src/Command/Phinx/MarkMigrated.php deleted file mode 100644 index 48500f316..000000000 --- a/src/Command/Phinx/MarkMigrated.php +++ /dev/null @@ -1,229 +0,0 @@ -output = $output; - } - - return $this->output; - } - - /** - * Configures the current command. - * - * @return void - */ - protected function configure(): void - { - $this->setName('mark_migrated') - ->setDescription('Mark a migration as migrated') - ->addArgument( - 'version', - InputArgument::OPTIONAL, - 'DEPRECATED: use `bin/cake migrations mark_migrated --target=VERSION --only` instead', - ) - ->setHelp(sprintf( - '%sMark migrations as migrated%s', - PHP_EOL, - PHP_EOL, - )) - ->addOption('plugin', 'p', InputOption::VALUE_REQUIRED, 'The plugin the file should be created for') - ->addOption('connection', 'c', InputOption::VALUE_REQUIRED, 'The datasource connection to use') - ->addOption('source', 's', InputOption::VALUE_REQUIRED, 'The folder where migrations are in') - ->addOption( - 'target', - 't', - InputOption::VALUE_REQUIRED, - 'It will mark migrations from beginning to the given version', - ) - ->addOption( - 'exclude', - 'x', - InputOption::VALUE_NONE, - 'If present it will mark migrations from beginning until the given version, excluding it', - ) - ->addOption( - 'only', - 'o', - InputOption::VALUE_NONE, - 'If present it will only mark the given migration version', - ); - } - - /** - * Mark migrations as migrated - * - * `bin/cake migrations mark_migrated` mark every migration as migrated - * `bin/cake migrations mark_migrated all` DEPRECATED: the same effect as above - * `bin/cake migrations mark_migrated --target=VERSION` mark migrations as migrated up to the VERSION param - * `bin/cake migrations mark_migrated --target=20150417223600 --exclude` mark migrations as migrated up to - * and except the VERSION param - * `bin/cake migrations mark_migrated --target=20150417223600 --only` mark only the VERSION migration as migrated - * `bin/cake migrations mark_migrated 20150417223600` DEPRECATED: the same effect as above - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @param \Symfony\Component\Console\Output\OutputInterface $output the output object - * @return int - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { - $this->setInput($input); - $this->bootstrap($input, $output); - $this->output($output); - - $migrationPaths = $this->getConfig()->getMigrationPaths(); - /** @var string $path */ - $path = array_pop($migrationPaths); - - if ($this->invalidOnlyOrExclude()) { - $output->writeln( - 'You should use `--exclude` OR `--only` (not both) along with a `--target` !', - ); - - return BaseCommand::CODE_ERROR; - } - - if ($this->isUsingDeprecatedAll()) { - $this->outputDeprecatedAllMessage(); - } - - if ($this->isUsingDeprecatedVersion()) { - $this->outputDeprecatedVersionMessage(); - } - - try { - $versions = $this->getManager()->getVersionsToMark($input); - } catch (InvalidArgumentException $e) { - $output->writeln(sprintf('%s', $e->getMessage())); - - return BaseCommand::CODE_ERROR; - } - - $this->getManager()->markVersionsAsMigrated($path, $versions, $output); - - return BaseCommand::CODE_SUCCESS; - } - - /** - * Checks if the version is using the deprecated `all` - * - * @return bool Returns true if it is using the deprecated `all` otherwise false - */ - protected function isUsingDeprecatedAll(): bool - { - $version = $this->input()->getArgument('version'); - - return $version === 'all' || $version === '*'; - } - - /** - * Checks if the input has the `--exclude` option - * - * @return bool Returns true if `--exclude` option gets passed in otherwise false - */ - protected function hasExclude(): bool - { - return (bool)$this->input()->getOption('exclude'); - } - - /** - * Checks if the input has the `--only` option - * - * @return bool Returns true if `--only` option gets passed in otherwise false - */ - protected function hasOnly(): bool - { - return (bool)$this->input()->getOption('only'); - } - - /** - * Checks for the usage of deprecated VERSION as argument when not `all` - * - * @return bool True if it is using VERSION argument otherwise false - */ - protected function isUsingDeprecatedVersion(): bool - { - $version = $this->input()->getArgument('version'); - - return $version && $version !== 'all' && $version !== '*'; - } - - /** - * Checks for an invalid use of `--exclude` or `--only` - * - * @return bool Returns true when it is an invalid use of `--exclude` or `--only` otherwise false - */ - protected function invalidOnlyOrExclude(): bool - { - return ($this->hasExclude() && $this->hasOnly()) || - ($this->hasExclude() || $this->hasOnly()) && - $this->input()->getOption('target') === null; - } - - /** - * Outputs the deprecated message for the `all` or `*` usage - * - * @return void Just outputs the message - */ - protected function outputDeprecatedAllMessage(): void - { - $msg = 'DEPRECATED: `all` or `*` as version is deprecated. Use `bin/cake migrations mark_migrated` instead'; - $output = $this->output(); - $output->writeln(sprintf('%s', $msg)); - } - - /** - * Outputs the deprecated message for the usage of VERSION as argument - * - * @return void Just outputs the message - */ - protected function outputDeprecatedVersionMessage(): void - { - $msg = 'DEPRECATED: VERSION as argument is deprecated. Use: ' . - '`bin/cake migrations mark_migrated --target=VERSION --only`'; - $output = $this->output(); - $output->writeln(sprintf('%s', $msg)); - } -} diff --git a/src/Command/Phinx/Migrate.php b/src/Command/Phinx/Migrate.php deleted file mode 100644 index 7200a40e6..000000000 --- a/src/Command/Phinx/Migrate.php +++ /dev/null @@ -1,97 +0,0 @@ - - */ - use EventDispatcherTrait; - - /** - * Configures the current command. - * - * @return void - */ - protected function configure(): void - { - $this->setName('migrate') - ->setDescription('Migrate the database') - ->setHelp('runs all available migrations, optionally up to a specific version') - ->addOption('--target', '-t', InputOption::VALUE_REQUIRED, 'The version number to migrate to') - ->addOption('--date', '-d', InputOption::VALUE_REQUIRED, 'The date to migrate to') - ->addOption('--count', '-k', InputOption::VALUE_REQUIRED, 'The number of migrations to run') - ->addOption( - '--dry-run', - '-x', - InputOption::VALUE_NONE, - 'Dump queries to standard output instead of executing it', - ) - ->addOption( - '--plugin', - '-p', - InputOption::VALUE_REQUIRED, - 'The plugin containing the migrations', - ) - ->addOption('--connection', '-c', InputOption::VALUE_REQUIRED, 'The datasource connection to use') - ->addOption('--source', '-s', InputOption::VALUE_REQUIRED, 'The folder where migrations are in') - ->addOption( - '--fake', - null, - InputOption::VALUE_NONE, - "Mark any migrations selected as run, but don't actually execute them", - ) - ->addOption( - '--no-lock', - null, - InputOption::VALUE_NONE, - 'If present, no lock file will be generated after migrating', - ); - } - - /** - * Overrides the action execute method in order to vanish the idea of environments - * from phinx. CakePHP does not believe in the idea of having in-app environments - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @param \Symfony\Component\Console\Output\OutputInterface $output the output object - * @return int - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { - $event = $this->dispatchEvent('Migration.beforeMigrate'); - if ($event->isStopped()) { - return $event->getResult() ? BaseCommand::CODE_SUCCESS : BaseCommand::CODE_ERROR; - } - $result = $this->parentExecute($input, $output); - $this->dispatchEvent('Migration.afterMigrate'); - - return $result; - } -} diff --git a/src/Command/Phinx/Rollback.php b/src/Command/Phinx/Rollback.php deleted file mode 100644 index 211ac17e5..000000000 --- a/src/Command/Phinx/Rollback.php +++ /dev/null @@ -1,92 +0,0 @@ - - */ - use EventDispatcherTrait; - - /** - * Configures the current command. - * - * @return void - */ - protected function configure(): void - { - $this->setName('rollback') - ->setDescription('Rollback the last or to a specific migration') - ->setHelp('reverts the last migration, or optionally up to a specific version') - ->addOption('--target', '-t', InputOption::VALUE_REQUIRED, 'The version number to rollback to') - ->addOption('--date', '-d', InputOption::VALUE_REQUIRED, 'The date to migrate to') - ->addOption('--plugin', '-p', InputOption::VALUE_REQUIRED, 'The plugin containing the migrations') - ->addOption('--connection', '-c', InputOption::VALUE_REQUIRED, 'The datasource connection to use') - ->addOption('--source', '-s', InputOption::VALUE_REQUIRED, 'The folder where migrations are in') - ->addOption('--force', '-f', InputOption::VALUE_NONE, 'Force rollback to ignore breakpoints') - ->addOption( - '--dry-run', - '-x', - InputOption::VALUE_NONE, - 'Dump queries to standard output instead of executing it', - ) - ->addOption( - '--fake', - null, - InputOption::VALUE_NONE, - "Mark any rollbacks selected as run, but don't actually execute them", - ) - ->addOption( - '--no-lock', - null, - InputOption::VALUE_NONE, - 'Whether a lock file should be generated after rolling back', - ); - } - - /** - * Overrides the action execute method in order to vanish the idea of environments - * from phinx. CakePHP does not believe in the idea of having in-app environments - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @param \Symfony\Component\Console\Output\OutputInterface $output the output object - * @return int - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { - $event = $this->dispatchEvent('Migration.beforeRollback'); - if ($event->isStopped()) { - return $event->getResult() ? BaseCommand::CODE_SUCCESS : BaseCommand::CODE_ERROR; - } - $result = $this->parentExecute($input, $output); - $this->dispatchEvent('Migration.afterRollback'); - - return $result; - } -} diff --git a/src/Command/Phinx/Seed.php b/src/Command/Phinx/Seed.php deleted file mode 100644 index 47c6f9157..000000000 --- a/src/Command/Phinx/Seed.php +++ /dev/null @@ -1,86 +0,0 @@ - - */ - use EventDispatcherTrait; - - /** - * Configures the current command. - * - * @return void - */ - protected function configure(): void - { - $this->setName('seed') - ->setDescription('Seed the database with data') - ->setHelp('runs all available migrations, optionally up to a specific version') - ->addOption( - '--seed', - null, - InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - 'What is the name of the seeder?', - ) - ->addOption('--plugin', '-p', InputOption::VALUE_REQUIRED, 'The plugin containing the migrations') - ->addOption('--connection', '-c', InputOption::VALUE_REQUIRED, 'The datasource connection to use') - ->addOption('--source', '-s', InputOption::VALUE_REQUIRED, 'The folder where migrations are in'); - } - - /** - * Overrides the action execute method in order to vanish the idea of environments - * from phinx. CakePHP does not believe in the idea of having in-app environments - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @param \Symfony\Component\Console\Output\OutputInterface $output the output object - * @return int - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { - $event = $this->dispatchEvent('Migration.beforeSeed'); - if ($event->isStopped()) { - return $event->getResult() ? BaseCommand::CODE_SUCCESS : BaseCommand::CODE_ERROR; - } - - $seed = $input->getOption('seed'); - if ($seed && !is_array($seed)) { - $input->setOption('seed', [$seed]); - } - - $this->setInput($input); - $this->bootstrap($input, $output); - $this->getManager()->setInput($input); - $result = $this->parentExecute($input, $output); - $this->dispatchEvent('Migration.afterSeed'); - - return $result; - } -} diff --git a/src/Command/Phinx/Status.php b/src/Command/Phinx/Status.php deleted file mode 100644 index eada1c0e9..000000000 --- a/src/Command/Phinx/Status.php +++ /dev/null @@ -1,141 +0,0 @@ -setName('status') - ->setDescription('Show migration status') - ->addOption( - '--format', - '-f', - InputOption::VALUE_REQUIRED, - 'The output format: text or json. Defaults to text.', - ) - ->setHelp('prints a list of all migrations, along with their current status') - ->addOption('--plugin', '-p', InputOption::VALUE_REQUIRED, 'The plugin containing the migrations') - ->addOption('--connection', '-c', InputOption::VALUE_REQUIRED, 'The datasource connection to use') - ->addOption('--source', '-s', InputOption::VALUE_REQUIRED, 'The folder where migrations are in'); - } - - /** - * Show the migration status. - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @param \Symfony\Component\Console\Output\OutputInterface $output the output object - * @return int - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { - $this->beforeExecute($input, $output); - $this->bootstrap($input, $output); - - /** @var string|null $environment */ - $environment = $input->getOption('environment'); - /** @var string|null $format */ - $format = $input->getOption('format'); - - if ($environment === null) { - $environment = $this->getManager()->getConfig()->getDefaultEnvironment(); - $output->writeln('warning no environment specified, defaulting to: ' . $environment); - } else { - $output->writeln('using environment ' . $environment); - } - if ($format !== null) { - $output->writeln('using format ' . $format); - } - - $migrations = $this->getManager()->printStatus($environment, $format); - - switch ($format) { - case 'json': - $flags = 0; - if ($input->getOption('verbose')) { - $flags = JSON_PRETTY_PRINT; - } - $migrationString = (string)json_encode($migrations, $flags); - $this->getManager()->getOutput()->writeln($migrationString); - break; - default: - $this->display($migrations); - break; - } - - return BaseCommand::CODE_SUCCESS; - } - - /** - * Will output the status of the migrations - * - * @param array> $migrations Migrations array. - * @return void - */ - protected function display(array $migrations): void - { - $output = $this->getManager()->getOutput(); - - if ($migrations) { - $output->writeln(''); - $output->writeln(' Status Migration ID Migration Name '); - $output->writeln('-----------------------------------------'); - - foreach ($migrations as $migration) { - $status = $migration['status'] === 'up' ? ' up ' : ' down '; - $maxNameLength = $this->getManager()->maxNameLength; - $name = $migration['name'] ? - ' ' . str_pad($migration['name'], $maxNameLength, ' ') . ' ' : - ' ** MISSING **'; - - $missingComment = ''; - if (!empty($migration['missing'])) { - $missingComment = ' ** MISSING **'; - } - - $output->writeln( - $status . - sprintf(' %14.0f ', $migration['id']) . - $name . - $missingComment, - ); - } - - $output->writeln(''); - } else { - $msg = 'There are no available migrations. Try creating one using the create command.'; - $output->writeln(''); - $output->writeln($msg); - $output->writeln(''); - } - } -} diff --git a/src/Command/RollbackCommand.php b/src/Command/RollbackCommand.php index de33c27ab..d3647f542 100644 --- a/src/Command/RollbackCommand.php +++ b/src/Command/RollbackCommand.php @@ -19,7 +19,6 @@ use Cake\Console\ConsoleOptionParser; use Cake\Event\EventDispatcherTrait; use DateTime; -use Exception; use InvalidArgumentException; use LogicException; use Migrations\Config\ConfigInterface; @@ -183,11 +182,6 @@ protected function executeMigrations(Arguments $args, ConsoleIo $io): ?int $manager->rollback($target, $force, $targetMustMatch, $fake); } $end = microtime(true); - } catch (Exception $e) { - $io->err('' . $e->getMessage() . ''); - $io->verbose($e->getTraceAsString()); - - return self::CODE_ERROR; } catch (Throwable $e) { $io->err('' . $e->getMessage() . ''); $io->verbose($e->getTraceAsString()); diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php index 8b53d9b7d..63acbe580 100644 --- a/src/Command/SeedCommand.php +++ b/src/Command/SeedCommand.php @@ -17,10 +17,11 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; -use Cake\Core\Configure; use Cake\Event\EventDispatcherTrait; use Migrations\Config\ConfigInterface; use Migrations\Migration\ManagerFactory; +use Migrations\Util\Util; +use Throwable; /** * Seed command runs seeder scripts @@ -39,7 +40,7 @@ class SeedCommand extends Command */ public static function defaultName(): string { - return 'migrations seed'; + return 'seeds run'; } /** @@ -50,33 +51,53 @@ public static function defaultName(): string */ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser { - $parser->setDescription([ + $description = [ 'Seed the database with data', '', - 'Runs a seeder script that can populate the database with data, or run mutations', + 'Runs a seeder script that can populate the database with data, or run mutations:', '', - 'migrations seed --connection secondary --seed UserSeed', + 'seeds run Posts', + 'seeds run Users,Posts', + 'seeds run --plugin Demo', + 'seeds run --connection secondary', '', - 'The `--seed` option can be supplied multiple times to run more than one seed', - ])->addOption('plugin', [ - 'short' => 'p', - 'help' => 'The plugin to run seeds in', - ])->addOption('connection', [ - 'short' => 'c', - 'help' => 'The datasource connection to use', - 'default' => 'default', - ])->addOption('dry-run', [ - 'short' => 'x', - 'help' => 'Dump queries to stdout instead of executing them', - 'boolean' => true, - ])->addOption('source', [ - 'short' => 's', - 'default' => ConfigInterface::DEFAULT_SEED_FOLDER, - 'help' => 'The folder where your seeds are.', - ])->addOption('seed', [ - 'help' => 'The name of the seed that you want to run.', - 'multiple' => true, - ]); + 'Runs all seeds if no seed names are specified. When running all seeds', + 'in an interactive terminal, a confirmation prompt is shown.', + ]; + + $parser->setDescription($description) + ->addArgument('seed', [ + 'help' => 'The name(s) of the seed(s) to run (comma-separated for multiple). Run all seeds if not specified.', + 'required' => false, + ]) + ->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to run seeds in', + ]) + ->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ]) + ->addOption('dry-run', [ + 'short' => 'd', + 'help' => 'Dump queries to stdout instead of executing them', + 'boolean' => true, + ]) + ->addOption('source', [ + 'short' => 's', + 'default' => ConfigInterface::DEFAULT_SEED_FOLDER, + 'help' => 'The folder where your seeds are.', + ]) + ->addOption('force', [ + 'short' => 'f', + 'help' => 'Force re-running seeds that have already been executed', + 'boolean' => true, + ]) + ->addOption('fake', [ + 'help' => 'Mark seeds as executed without actually running them', + 'boolean' => true, + ]); return $parser; } @@ -119,29 +140,86 @@ protected function executeSeeds(Arguments $args, ConsoleIo $io): ?int $manager = $factory->createManager($io); $config = $manager->getConfig(); - if (version_compare(Configure::version(), '5.2.0', '>=')) { - $seeds = (array)$args->getArrayOption('seed'); - } else { - $seeds = (array)$args->getMultipleOption('seed'); + // Get seed names from arguments + $seeds = []; + if ($args->hasArgument('seed')) { + $seedArg = $args->getArgument('seed'); + if ($seedArg !== null) { + // Split by comma to support comma-separated list + $seedList = explode(',', $seedArg); + foreach ($seedList as $seed) { + $trimmed = trim($seed); + if ($trimmed !== '') { + $seeds[] = $trimmed; + } + } + } } $versionOrder = $config->getVersionOrder(); + $fake = (bool)$args->getOption('fake'); + if ($config->isDryRun()) { $io->info('DRY-RUN mode enabled'); } + if ($fake) { + $io->warning('performing fake seeding'); + } $io->verbose('using connection ' . (string)$args->getOption('connection')); $io->verbose('using paths ' . $config->getMigrationPath()); $io->verbose('ordering by ' . $versionOrder . ' time'); $start = microtime(true); if (!$seeds) { + // Get all available seeds and ask for confirmation + try { + $availableSeeds = $manager->getSeeds(); + } catch (Throwable $e) { + $io->err('Failed to load seeds: ' . $e->getMessage() . ''); + $io->verbose($e->getTraceAsString()); + + return static::CODE_ERROR; + } + + if (!$availableSeeds) { + $io->warning('No seeds found.'); + + return self::CODE_SUCCESS; + } + + // Skip confirmation in quiet mode + if ($io->level() > ConsoleIo::QUIET) { + $io->out(''); + $io->out('The following seeds will be executed:'); + foreach ($availableSeeds as $seed) { + $io->out(' - ' . Util::getSeedDisplayName($seed->getName())); + } + $io->out(''); + if (!(bool)$args->getOption('force')) { + $io->out('Note: Seeds that have already been executed will be skipped.'); + $io->out('Use --force to re-run seeds.'); + } else { + $io->out('Warning: Running with --force will re-execute all seeds,'); + $io->out('potentially creating duplicate data. Ensure your seeds are idempotent.'); + } + $io->out(''); + + // Ask for confirmation + $continue = $io->askChoice('Do you want to continue?', ['y', 'n'], 'n'); + if ($continue !== 'y') { + $io->warning('Seed operation aborted.'); + + return self::CODE_SUCCESS; + } + } + // run all the seed(ers) - $manager->seed(); + $manager->seed(null, (bool)$args->getOption('force'), $fake); } else { - // run seed(ers) specified in a comma-separated list of classes + // run seed(ers) specified as arguments foreach ($seeds as $seed) { - $manager->seed(trim($seed)); + $manager->seed(trim($seed), (bool)$args->getOption('force'), $fake); } } $end = microtime(true); diff --git a/src/Command/SeedResetCommand.php b/src/Command/SeedResetCommand.php new file mode 100644 index 000000000..f11964d03 --- /dev/null +++ b/src/Command/SeedResetCommand.php @@ -0,0 +1,174 @@ +setDescription([ + 'The reset command removes seed execution records from the log', + 'allowing seeds to be re-run without the --force flag.', + '', + 'seeds reset', + 'seeds reset --seed Users', + 'seeds reset --seed Users,Posts', + 'seeds reset --plugin Demo', + 'seeds reset -c secondary', + ])->addOption('seed', [ + 'help' => 'Comma-separated list of specific seeds to reset. Resets all seeds if not specified.', + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to reset seeds for', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'help' => 'The folder under config that seeds are in', + 'default' => ConfigInterface::DEFAULT_SEED_FOLDER, + ])->addOption('dry-run', [ + 'short' => 'd', + 'help' => 'Show what would be reset without actually doing it', + 'boolean' => true, + ]); + + return $parser; + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $factory = new ManagerFactory([ + 'plugin' => $args->getOption('plugin'), + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + 'dry-run' => (bool)$args->getOption('dry-run'), + ]); + + $manager = $factory->createManager($io); + $config = $manager->getConfig(); + + if ($config->isDryRun()) { + $io->info('DRY-RUN mode enabled'); + } + + $io->verbose('using connection ' . (string)$args->getOption('connection')); + $io->verbose('using paths ' . $config->getSeedPath()); + + $seeds = $manager->getSeeds(); + $adapter = $manager->getEnvironment()->getAdapter(); + + // Filter seeds if --seed option is specified + $seedOption = $args->getOption('seed'); + $seedsToReset = $seeds; + + if ($seedOption) { + $requestedSeeds = array_map('trim', explode(',', (string)$seedOption)); + $seedsToReset = []; + + foreach ($requestedSeeds as $requestedSeed) { + $normalizedName = $manager->normalizeSeedName($requestedSeed, $seeds); + if ($normalizedName === null) { + $io->error("Seed `{$requestedSeed}` does not exist."); + + return self::CODE_ERROR; + } + $seedsToReset[$normalizedName] = $seeds[$normalizedName]; + } + } + + if (empty($seedsToReset)) { + $io->warning('No seeds to reset.'); + + return self::CODE_SUCCESS; + } + + // Show what will be reset and ask for confirmation + $io->out(''); + $resetAllMessage = $seedOption ? 'The following seeds will be reset:' : 'All seeds will be reset:'; + $io->out($resetAllMessage); + foreach ($seedsToReset as $seed) { + $io->out(' - ' . Util::getSeedDisplayName($seed->getName())); + } + $io->out(''); + + if (!$config->isDryRun()) { + $continue = $io->askChoice('Do you want to continue?', ['y', 'n'], 'n'); + if ($continue !== 'y') { + $io->warning('Reset operation aborted.'); + + return self::CODE_SUCCESS; + } + } + + // Reset the seeds + $count = 0; + foreach ($seedsToReset as $seed) { + $seedName = Util::getSeedDisplayName($seed->getName()); + if ($manager->isSeedExecuted($seed)) { + if (!$config->isDryRun()) { + $adapter->removeSeedFromLog($seed); + } + $io->info("Reset: {$seedName} seed"); + $count++; + } else { + $io->verbose("Skipped (not executed): {$seedName} seed"); + } + } + + $io->out(''); + if ($config->isDryRun()) { + $io->success("DRY-RUN: Would reset {$count} seed(s)."); + } else { + $io->success("Reset {$count} seed(s)."); + } + + return self::CODE_SUCCESS; + } +} diff --git a/src/Command/SeedStatusCommand.php b/src/Command/SeedStatusCommand.php new file mode 100644 index 000000000..bf4038e1a --- /dev/null +++ b/src/Command/SeedStatusCommand.php @@ -0,0 +1,186 @@ +setDescription([ + 'The status command prints a list of all seeds, along with their execution status', + '', + 'seeds status', + 'seeds status --plugin Demo', + 'seeds status -c secondary', + 'seeds status -f json', + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'The plugin to check seed status for', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('source', [ + 'short' => 's', + 'help' => 'The folder under config that seeds are in', + 'default' => ConfigInterface::DEFAULT_SEED_FOLDER, + ])->addOption('format', [ + 'short' => 'f', + 'help' => 'The output format: text or json. Defaults to text.', + 'choices' => ['text', 'json'], + 'default' => 'text', + ]); + + return $parser; + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $factory = new ManagerFactory([ + 'plugin' => $args->getOption('plugin'), + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + ]); + + $manager = $factory->createManager($io); + $config = $manager->getConfig(); + + $io->verbose('using connection ' . (string)$args->getOption('connection')); + $io->verbose('using paths ' . $config->getSeedPath()); + + $seeds = $manager->getSeeds(); + $adapter = $manager->getEnvironment()->getAdapter(); + + // Ensure seed schema table exists + if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) { + $adapter->createSeedSchemaTable(); + } + + $seedLog = $adapter->getSeedLog(); + + // Build status list + $statuses = []; + $appNamespace = Configure::read('App.namespace', 'App'); + foreach ($seeds as $seed) { + $plugin = null; + $className = get_class($seed); + + if (str_contains($className, '\\')) { + $parts = explode('\\', $className); + if (count($parts) > 1 && $parts[0] !== $appNamespace) { + $plugin = $parts[0]; + } + } + + $seedName = $seed->getName(); + $executed = false; + $executedAt = null; + + foreach ($seedLog as $entry) { + if ($entry['seed_name'] === $seedName && $entry['plugin'] === $plugin) { + $executed = true; + $executedAt = $entry['executed_at']; + break; + } + } + + // Strip 'Seed' suffix for display and add ' seed' suffix + $displayName = Util::getSeedDisplayName($seedName) . ' seed'; + + $statuses[] = [ + 'seedName' => $displayName, + 'plugin' => $plugin, + 'status' => $executed ? 'executed' : 'pending', + 'executedAt' => $executedAt, + ]; + } + + $format = (string)$args->getOption('format'); + if ($format === 'json') { + $json = json_encode($statuses, JSON_PRETTY_PRINT); + if ($json !== false) { + $io->out($json); + } + + return self::CODE_SUCCESS; + } + + // Text format + if (!$statuses) { + $io->warning('No seeds found.'); + + return self::CODE_SUCCESS; + } + + $io->out(''); + $io->out('Current seed execution status:'); + $io->out(''); + + $maxNameLength = max(array_map(fn($s) => strlen($s['seedName']), $statuses)); + $maxPluginLength = max(array_map(fn($s) => strlen($s['plugin'] ?? ''), $statuses)); + + foreach ($statuses as $status) { + $seedName = str_pad($status['seedName'], $maxNameLength); + $plugin = $status['plugin'] ? str_pad($status['plugin'], $maxPluginLength) : str_repeat(' ', $maxPluginLength); + + if ($status['status'] === 'executed') { + $statusText = 'executed'; + $date = $status['executedAt'] ? ' (' . $status['executedAt'] . ')' : ''; + $io->out(" {$statusText} {$plugin} {$seedName}{$date}"); + } else { + $statusText = 'pending '; + $io->out(" {$statusText} {$plugin} {$seedName}"); + } + } + + $io->out(''); + + return self::CODE_SUCCESS; + } +} diff --git a/src/Command/SeedsEntryCommand.php b/src/Command/SeedsEntryCommand.php new file mode 100644 index 000000000..e353b7db5 --- /dev/null +++ b/src/Command/SeedsEntryCommand.php @@ -0,0 +1,150 @@ +commands = $commands; + } + + /** + * Run the command. + * + * Override the run() method for special handling of the `--help` option. + * + * @param array $argv Arguments from the CLI environment. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null Exit code or null for success. + */ + public function run(array $argv, ConsoleIo $io): ?int + { + $this->initialize(); + + $parser = $this->getOptionParser(); + try { + [$options, $arguments] = $parser->parse($argv); + $args = new Arguments( + $arguments, + $options, + $parser->argumentNames(), + ); + } catch (ConsoleException $e) { + $io->err('Error: ' . $e->getMessage()); + + return static::CODE_ERROR; + } + $this->setOutputLevel($args, $io); + + // This is the variance from Command::run() + if (!$args->getArgumentAt(0) && $args->getOption('help')) { + $io->out([ + 'Seeds', + '', + 'Seeds provides commands for managing your application database seed data.', + '', + ]); + $help = $this->getHelp(); + $this->executeCommand($help, [], $io); + + return static::CODE_SUCCESS; + } + + return $this->execute($args, $io); + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + if ($args->hasArgumentAt(0)) { + $name = $args->getArgumentAt(0); + $io->err( + "Could not find seeds command named `$name`." + . ' Run `seeds --help` to get a list of commands.', + ); + + return static::CODE_ERROR; + } + $io->err('No command provided. Run `seeds --help` to get a list of commands.'); + + return static::CODE_ERROR; + } + + /** + * Gets the generated help command + * + * @return \Cake\Console\Command\HelpCommand + */ + public function getHelp(): HelpCommand + { + $help = new HelpCommand(); + $commands = []; + foreach ($this->commands as $command => $class) { + if (str_starts_with($command, 'seeds')) { + $parts = explode(' ', $command); + + // Remove `seeds` + array_shift($parts); + if (count($parts) === 0) { + continue; + } + $commands[$command] = $class; + } + } + + $CommandCollection = new CommandCollection($commands); + $help->setCommandCollection($CommandCollection); + + return $help; + } +} diff --git a/src/Command/SnapshotTrait.php b/src/Command/SnapshotTrait.php index 085d77968..1c4fad8d3 100644 --- a/src/Command/SnapshotTrait.php +++ b/src/Command/SnapshotTrait.php @@ -15,7 +15,6 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; -use Cake\Core\Configure; /** * Trait needed for all "snapshot" type of bake operations. @@ -41,15 +40,6 @@ protected function createFile(string $path, string $contents, Arguments $args, C return $createFile; } - /** - * @internal - * @return bool Whether the builtin backend is active. - */ - protected function useBuiltinBackend(): bool - { - return Configure::read('Migrations.backend', 'builtin') === 'builtin'; - } - /** * Will mark a snapshot created, the snapshot being identified by its * full file path. @@ -72,11 +62,7 @@ protected function markSnapshotApplied(string $path, Arguments $args, ConsoleIo $newArgs = array_merge($newArgs, $this->parseOptions($args)); $io->out('Marking the migration ' . $fileName . ' as migrated...'); - if ($this->useBuiltinBackend()) { - $this->executeCommand(MarkMigratedCommand::class, $newArgs, $io); - } else { - $this->executeCommand(MigrationsMarkMigratedCommand::class, $newArgs, $io); - } + $this->executeCommand(MarkMigratedCommand::class, $newArgs, $io); } /** @@ -92,11 +78,7 @@ protected function refreshDump(Arguments $args, ConsoleIo $io): void $newArgs = $this->parseOptions($args); $io->out('Creating a dump of the new database state...'); - if ($this->useBuiltinBackend()) { - $this->executeCommand(DumpCommand::class, $newArgs, $io); - } else { - $this->executeCommand(MigrationsDumpCommand::class, $newArgs, $io); - } + $this->executeCommand(DumpCommand::class, $newArgs, $io); } /** diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index 1c7734362..b84897468 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -64,7 +64,7 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar 'migrations status -c secondary', 'migrations status -c secondary -f json', 'migrations status --cleanup', - 'Remove *MISSING* migrations from the phinxlog table', + 'Remove *MISSING* migrations from the migration tracking table', ])->addOption('plugin', [ 'short' => 'p', 'help' => 'The plugin to run migrations for', @@ -82,7 +82,7 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar 'choices' => ['text', 'json'], 'default' => 'text', ])->addOption('cleanup', [ - 'help' => 'Remove MISSING migrations from the phinxlog table', + 'help' => 'Remove MISSING migrations from the migration tracking table', 'boolean' => true, 'default' => false, ]); @@ -123,6 +123,7 @@ public function execute(Arguments $args, ConsoleIo $io): ?int } $migrations = $manager->printStatus($format); + $tableName = $manager->getSchemaTableName(); switch ($format) { case 'json': @@ -134,7 +135,7 @@ public function execute(Arguments $args, ConsoleIo $io): ?int $io->out($migrationString); break; default: - $this->display($migrations, $io); + $this->display($migrations, $io, $tableName); break; } @@ -146,10 +147,14 @@ public function execute(Arguments $args, ConsoleIo $io): ?int * * @param array $migrations * @param \Cake\Console\ConsoleIo $io The console io + * @param string $tableName The migration tracking table name * @return void */ - protected function display(array $migrations, ConsoleIo $io): void + protected function display(array $migrations, ConsoleIo $io, string $tableName): void { + $io->out(sprintf('using migration table %s', $tableName)); + $io->out(''); + if ($migrations) { $rows = []; $rows[] = ['Status', 'Migration ID', 'Migration Name']; diff --git a/src/Command/UpgradeCommand.php b/src/Command/UpgradeCommand.php new file mode 100644 index 000000000..1d766da2b --- /dev/null +++ b/src/Command/UpgradeCommand.php @@ -0,0 +1,295 @@ +setDescription([ + 'Upgrades migration tracking from legacy phinxlog tables to unified cake_migrations table.', + '', + 'This command migrates data from:', + ' - phinxlog (app migrations)', + ' - {plugin}_phinxlog (plugin migrations)', + '', + 'To the unified cake_migrations table with a plugin column.', + '', + 'After running this command, set Migrations.legacyTables = false', + 'in your configuration to use the new table.', + '', + 'migrations upgrade --dry-run Preview changes', + 'migrations upgrade Execute the upgrade', + ])->addOption('connection', [ + 'short' => 'c', + 'help' => 'The datasource connection to use', + 'default' => 'default', + ])->addOption('dry-run', [ + 'boolean' => true, + 'help' => 'Preview what would be migrated without making changes', + 'default' => false, + ])->addOption('drop-tables', [ + 'boolean' => true, + 'help' => 'Drop legacy phinxlog tables after migration', + 'default' => false, + ]); + + return $parser; + } + + /** + * Execute the command. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get((string)$args->getOption('connection')); + $dryRun = (bool)$args->getOption('dry-run'); + $dropTables = (bool)$args->getOption('drop-tables'); + + if ($dryRun) { + $io->out('DRY RUN - No changes will be made'); + $io->out(''); + } + + // Find all legacy phinxlog tables + $legacyTables = $this->findLegacyTables($connection); + + if ($legacyTables === []) { + $io->out('No phinxlog tables found. Nothing to upgrade.'); + + return self::CODE_SUCCESS; + } + + $io->out(sprintf('Found %d phinxlog table(s):', count($legacyTables))); + foreach ($legacyTables as $table => $plugin) { + $pluginLabel = $plugin === null ? '(app)' : "({$plugin})"; + $io->out(" - {$table} {$pluginLabel}"); + } + $io->out(''); + + // Create unified table if needed + $unifiedTableName = UnifiedMigrationsTableStorage::TABLE_NAME; + if (!$this->tableExists($connection, $unifiedTableName)) { + $io->out("Creating unified table {$unifiedTableName}..."); + if (!$dryRun) { + $this->createUnifiedTable($connection, $io); + } + } else { + $io->out("Unified table {$unifiedTableName} already exists."); + } + $io->out(''); + + // Migrate data from each legacy table + $totalMigrated = 0; + foreach ($legacyTables as $tableName => $plugin) { + $count = $this->migrateTable($connection, $tableName, $plugin, $dryRun, $io); + $totalMigrated += $count; + } + + $io->out(''); + $io->out(sprintf('Total records migrated: %d', $totalMigrated)); + + if (!$dryRun) { + // Clean up legacy tables + $io->out(''); + foreach ($legacyTables as $tableName => $plugin) { + if ($dropTables) { + $io->out("Dropping legacy table {$tableName}..."); + $connection->execute("DROP TABLE {$connection->getDriver()->quoteIdentifier($tableName)}"); + } else { + $io->out('Retaining legacy table. You should drop these tables once you have verified your upgrade.'); + } + } + + $io->out(''); + $io->success('Upgrade complete!'); + $io->out(''); + $io->out('Next steps:'); + $io->out(' 1. Set \'Migrations\' => [\'legacyTables\' => false] in your config'); + $io->out(' 2. Test your application'); + if (!$dropTables) { + $io->out(' 3. Optionally drop the empty phinxlog tables (re-run `bin/cake migrations upgrade --drop-tables`)'); + } + } else { + $io->out(''); + $io->out('This was a dry run. Run without --dry-run to execute.'); + } + + return self::CODE_SUCCESS; + } + + /** + * Find all legacy phinxlog tables in the database. + * + * @param \Cake\Database\Connection $connection Database connection + * @return array Map of table name => plugin name (null for app) + */ + protected function findLegacyTables(Connection $connection): array + { + $schema = $connection->getDriver()->schemaDialect(); + $tables = $schema->listTables(); + $legacyTables = []; + + foreach ($tables as $table) { + if ($table === 'phinxlog') { + $legacyTables[$table] = null; + } elseif (str_ends_with($table, '_phinxlog')) { + // Extract plugin name from table name + $prefix = substr($table, 0, -9); // Remove '_phinxlog' + $plugin = Inflector::camelize($prefix); + $legacyTables[$table] = $plugin; + } + } + + return $legacyTables; + } + + /** + * Check if a table exists. + * + * @param \Cake\Database\Connection $connection Database connection + * @param string $tableName Table name + * @return bool + */ + protected function tableExists(Connection $connection, string $tableName): bool + { + $schema = $connection->getDriver()->schemaDialect(); + + return $schema->hasTable($tableName); + } + + /** + * Create the unified migrations table. + * + * @param \Cake\Database\Connection $connection Database connection + * @param \Cake\Console\ConsoleIo $io Console IO + * @return void + */ + protected function createUnifiedTable(Connection $connection, ConsoleIo $io): void + { + $factory = new ManagerFactory([ + 'plugin' => null, + 'source' => null, + 'connection' => $connection->configName(), + // This doesn't follow the cli flag as this method is only called when creating the table. + 'dry-run' => false, + ]); + + $manager = $factory->createManager($io); + $adapter = $manager->getEnvironment()->getAdapter(); + if ($adapter instanceof WrapperInterface) { + $adapter = $adapter->getAdapter(); + } + assert($adapter instanceof AbstractAdapter, 'adapter must be an AbstractAdapter'); + + $storage = new UnifiedMigrationsTableStorage($adapter); + $storage->createTable(); + } + + /** + * Migrate data from a phinx table to the unified table. + * + * @param \Cake\Database\Connection $connection Database connection + * @param string $tableName Legacy table name + * @param string|null $plugin Plugin name (null for app) + * @param bool $dryRun Whether this is a dry run + * @param \Cake\Console\ConsoleIo $io Console IO + * @return int Number of records migrated + */ + protected function migrateTable( + Connection $connection, + string $tableName, + ?string $plugin, + bool $dryRun, + ConsoleIo $io, + ): int { + $unifiedTable = UnifiedMigrationsTableStorage::TABLE_NAME; + $pluginLabel = $plugin ?? 'app'; + + // Read all records from legacy table + $query = $connection->selectQuery() + ->select('*') + ->from($tableName); + $rows = $query->execute()->fetchAll('assoc'); + + $count = count($rows); + $io->out("Migrating {$count} record(s) from {$tableName} ({$pluginLabel})..."); + + if ($dryRun || $count === 0) { + return $count; + } + + // Insert into unified table + foreach ($rows as $row) { + try { + $insertQuery = $connection->insertQuery() + ->insert(['version', 'migration_name', 'plugin', 'start_time', 'end_time', 'breakpoint']) + ->into($unifiedTable) + ->values([ + 'version' => $row['version'], + 'migration_name' => $row['migration_name'] ?? null, + 'plugin' => $plugin, + 'start_time' => $row['start_time'] ?? null, + 'end_time' => $row['end_time'] ?? null, + 'breakpoint' => $row['breakpoint'] ?? 0, + ]); + $insertQuery->execute(); + } catch (QueryException $e) { + $io->out('Already migrated ' . $row['migration_name'] . '.'); + } + } + + return $count; + } +} diff --git a/src/Config/Config.php b/src/Config/Config.php index 313506f33..03361772d 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -28,9 +28,6 @@ class Config implements ConfigInterface */ public const VERSION_ORDER_EXECUTION_TIME = 'execution'; - public const TEMPLATE_STYLE_CHANGE = 'change'; - public const TEMPLATE_STYLE_UP_DOWN = 'up_down'; - /** * @var array */ @@ -90,28 +87,6 @@ public function getSeedPath(): string return $this->values['paths']['seeds']; } - /** - * @inheritdoc - */ - public function getMigrationBaseClassName(bool $dropNamespace = true): string - { - /** @var string $className */ - $className = !isset($this->values['migration_base_class']) ? 'Phinx\Migration\AbstractMigration' : $this->values['migration_base_class']; - - return $dropNamespace ? (substr((string)strrchr($className, '\\'), 1) ?: $className) : $className; - } - - /** - * @inheritdoc - */ - public function getSeedBaseClassName(bool $dropNamespace = true): string - { - /** @var string $className */ - $className = !isset($this->values['seed_base_class']) ? 'Phinx\Seed\AbstractSeed' : $this->values['seed_base_class']; - - return $dropNamespace ? substr((string)strrchr($className, '\\'), 1) : $className; - } - /** * @inheritdoc */ @@ -120,42 +95,6 @@ public function getConnection(): string|false return $this->values['environment']['connection'] ?? false; } - /** - * @inheritdoc - */ - public function getTemplateFile(): string|false - { - if (!isset($this->values['templates']['file'])) { - return false; - } - - return $this->values['templates']['file']; - } - - /** - * @inheritdoc - */ - public function getTemplateClass(): string|false - { - if (!isset($this->values['templates']['class'])) { - return false; - } - - return $this->values['templates']['class']; - } - - /** - * @inheritdoc - */ - public function getTemplateStyle(): string - { - if (!isset($this->values['templates']['style'])) { - return self::TEMPLATE_STYLE_CHANGE; - } - - return $this->values['templates']['style'] === self::TEMPLATE_STYLE_UP_DOWN ? self::TEMPLATE_STYLE_UP_DOWN : self::TEMPLATE_STYLE_CHANGE; - } - /** * @inheritdoc */ @@ -236,12 +175,4 @@ public function offsetUnset($offset): void { unset($this->values[$offset]); } - - /** - * @inheritdoc - */ - public function getSeedTemplateFile(): ?string - { - return $this->values['templates']['seedFile'] ?? null; - } } diff --git a/src/Config/ConfigInterface.php b/src/Config/ConfigInterface.php index 2a0191659..57e4ffc7f 100644 --- a/src/Config/ConfigInterface.php +++ b/src/Config/ConfigInterface.php @@ -11,7 +11,7 @@ use ArrayAccess; /** - * Phinx configuration interface. + * Configuration interface. * * @template-implemements ArrayAccess */ @@ -51,27 +51,6 @@ public function getSeedPath(): string; */ public function getConnection(): string|false; - /** - * Get the template file name. - * - * @return string|false - */ - public function getTemplateFile(): string|false; - - /** - * Get the template class name. - * - * @return string|false - */ - public function getTemplateClass(): string|false; - - /** - * Get the template style to use, either change or up_down. - * - * @return string - */ - public function getTemplateStyle(): string; - /** * Get the version order. * @@ -86,29 +65,6 @@ public function getVersionOrder(): string; */ public function isVersionOrderCreationTime(): bool; - /** - * Gets the base class name for migrations. - * - * @param bool $dropNamespace Return the base migration class name without the namespace. - * @return string - */ - public function getMigrationBaseClassName(bool $dropNamespace = true): string; - - /** - * Gets the base class name for seeds. - * - * @param bool $dropNamespace Return the base seed class name without the namespace. - * @return string - */ - public function getSeedBaseClassName(bool $dropNamespace = true): string; - - /** - * Get the seeder template file name or null if not set. - * - * @return string|null - */ - public function getSeedTemplateFile(): ?string; - /** * Should queries be sent to the database or just print to stdout? * diff --git a/src/ConfigurationTrait.php b/src/ConfigurationTrait.php deleted file mode 100644 index caba2343f..000000000 --- a/src/ConfigurationTrait.php +++ /dev/null @@ -1,336 +0,0 @@ -input === null) { - throw new RuntimeException('Input not set'); - } - - return $this->input; - } - - /** - * Overrides the original method from phinx to just always return true to - * avoid calling loadConfig method which will throw an exception as we rely on - * the overridden getConfig method. - * - * @return bool - */ - public function hasConfig(): bool - { - return true; - } - - /** - * Overrides the original method from phinx in order to return a tailored - * Config object containing the connection details for the database. - * - * @param bool $forceRefresh Refresh config. - * @return \Phinx\Config\ConfigInterface - */ - public function getConfig(bool $forceRefresh = false): ConfigInterface - { - if ($this->configuration && $forceRefresh === false) { - return $this->configuration; - } - - $migrationsPath = $this->getOperationsPath($this->input()); - $seedsPath = $this->getOperationsPath($this->input(), 'Seeds'); - $plugin = $this->getPlugin($this->input()); - - if (!is_dir($migrationsPath)) { - if (!Configure::read('debug')) { - throw new RuntimeException(sprintf( - 'Migrations path `%s` does not exist and cannot be created because `debug` is disabled.', - $migrationsPath, - )); - } - mkdir($migrationsPath, 0777, true); - } - - if (Configure::read('debug') && !is_dir($seedsPath)) { - mkdir($seedsPath, 0777, true); - } - - $phinxTable = $this->getPhinxTable($plugin); - - $connection = $this->getConnectionName($this->input()); - - $connectionConfig = (array)ConnectionManager::getConfig($connection); - $adapterName = $this->getAdapterName($connectionConfig['driver']); - $dsnOptions = $this->extractDsnOptions($adapterName, $connectionConfig); - - $templatePath = dirname(__DIR__) . DS . 'templates' . DS; - $config = [ - 'paths' => [ - 'migrations' => $migrationsPath, - 'seeds' => $seedsPath, - ], - 'templates' => [ - 'file' => $templatePath . 'Phinx' . DS . 'create.php.template', - ], - 'migration_base_class' => 'Migrations\AbstractMigration', - 'environments' => [ - 'default_migration_table' => $phinxTable, - 'default_environment' => 'default', - 'default' => [ - 'adapter' => $adapterName, - 'host' => $connectionConfig['host'] ?? null, - 'user' => $connectionConfig['username'] ?? null, - 'pass' => $connectionConfig['password'] ?? null, - 'port' => $connectionConfig['port'] ?? null, - 'name' => $connectionConfig['database'], - 'charset' => $connectionConfig['encoding'] ?? null, - 'unix_socket' => $connectionConfig['unix_socket'] ?? null, - 'suffix' => '', - 'dsn_options' => $dsnOptions, - ], - ], - 'feature_flags' => $this->featureFlags(), - ]; - - if ($adapterName === 'pgsql') { - if (!empty($connectionConfig['schema'])) { - $config['environments']['default']['schema'] = $connectionConfig['schema']; - } - } - - if ($adapterName === 'mysql') { - if (!empty($connectionConfig['ssl_key']) && !empty($connectionConfig['ssl_cert'])) { - $config['environments']['default']['mysql_attr_ssl_key'] = $connectionConfig['ssl_key']; - $config['environments']['default']['mysql_attr_ssl_cert'] = $connectionConfig['ssl_cert']; - } - - if (!empty($connectionConfig['ssl_ca'])) { - $config['environments']['default']['mysql_attr_ssl_ca'] = $connectionConfig['ssl_ca']; - } - } - - if ($adapterName === 'sqlite') { - if (!empty($connectionConfig['cache'])) { - $config['environments']['default']['cache'] = $connectionConfig['cache']; - } - if (!empty($connectionConfig['mode'])) { - $config['environments']['default']['mode'] = $connectionConfig['mode']; - } - } - - if (!empty($connectionConfig['flags'])) { - $config['environments']['default'] += - $this->translateConnectionFlags($connectionConfig['flags'], $adapterName); - } - - return $this->configuration = new Config($config); - } - - /** - * Returns the Migrations feature flags configuration. - * - * @return array - */ - protected function featureFlags(): array - { - $options = [ - 'unsigned_primary_keys', - 'column_null_default', - ]; - - return array_intersect_key(Configure::read('Migrations', []), array_flip($options)); - } - - /** - * Returns the correct driver name to use in phinx based on the driver class - * that was configured for the configuration. - * - * @param string $driver The driver name as configured for the CakePHP app. - * @return string Name of the adapter. - * @throws \InvalidArgumentException when it was not possible to infer the information - * out of the provided database configuration - * @phpstan-param class-string $driver - */ - public function getAdapterName(string $driver): string - { - switch ($driver) { - case Mysql::class: - case is_a($driver, Mysql::class, true): - return 'mysql'; - case Postgres::class: - case is_a($driver, Postgres::class, true): - return 'pgsql'; - case Sqlite::class: - case is_a($driver, Sqlite::class, true): - return 'sqlite'; - case Sqlserver::class: - case is_a($driver, Sqlserver::class, true): - return 'sqlsrv'; - } - - throw new InvalidArgumentException('Could not infer database type from driver'); - } - - /** - * Returns the connection name that should be used for the migrations. - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @return string - */ - protected function getConnectionName(InputInterface $input): string - { - return $input->getOption('connection') ?: 'default'; - } - - /** - * Translates driver specific connection flags (PDO attributes) to - * Phinx compatible adapter options. - * - * Currently, Phinx supports of the following flags: - * - * - *Most* of `PDO::ATTR_*` - * - `PDO::MYSQL_ATTR_*` - * - `PDO::PGSQL_ATTR_*` - * - `PDO::SQLSRV_ATTR_*` - * - * ### Example: - * - * ``` - * [ - * \PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => false, - * \PDO::SQLSRV_ATTR_DIRECT_QUERY => true, - * // ... - * ] - * ``` - * - * will be translated to: - * - * ``` - * [ - * 'mysql_attr_ssl_verify_server_cert' => false, - * 'sqlsrv_attr_direct_query' => true, - * // ... - * ] - * ``` - * - * @param array $flags An array of connection flags. - * @param string $adapterName The adapter name, eg `mysql` or `sqlsrv`. - * @return array An array of Phinx compatible connection attribute options. - */ - protected function translateConnectionFlags(array $flags, string $adapterName): array - { - $pdo = new ReflectionClass(PDO::class); - $constants = $pdo->getConstants(); - - $attributes = []; - foreach ($constants as $name => $value) { - $name = strtolower($name); - if (strpos($name, "{$adapterName}_attr_") === 0 || strpos($name, 'attr_') === 0) { - $attributes[$value] = $name; - } - } - - $options = []; - foreach ($flags as $flag => $value) { - if (isset($attributes[$flag])) { - $options[$attributes[$flag]] = $value; - } - } - - return $options; - } - - /** - * Extracts DSN options from the connection configuration. - * - * @param string $adapterName The adapter name. - * @param array $config The connection configuration. - * @return array - */ - protected function extractDsnOptions(string $adapterName, array $config): array - { - $dsnOptionsMap = []; - - // SQLServer is currently the only Phinx adapter that supports DSN options - if ($adapterName === 'sqlsrv') { - $dsnOptionsMap = [ - 'connectionPooling' => 'ConnectionPooling', - 'failoverPartner' => 'Failover_Partner', - 'loginTimeout' => 'LoginTimeout', - 'multiSubnetFailover' => 'MultiSubnetFailover', - 'encrypt' => 'Encrypt', - 'trustServerCertificate' => 'TrustServerCertificate', - ]; - } - - $suppliedDsnOptions = array_intersect_key($dsnOptionsMap, $config); - - $dsnOptions = []; - foreach ($suppliedDsnOptions as $alias => $option) { - $dsnOptions[$option] = $config[$alias]; - } - - return $dsnOptions; - } -} diff --git a/src/Db/Action/Action.php b/src/Db/Action/Action.php index 66adb8080..4718b2682 100644 --- a/src/Db/Action/Action.php +++ b/src/Db/Action/Action.php @@ -8,21 +8,21 @@ namespace Migrations\Db\Action; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; abstract class Action { /** - * @var \Migrations\Db\Table\Table + * @var \Migrations\Db\Table\TableMetadata */ - protected Table $table; + protected TableMetadata $table; /** * Constructor * - * @param \Migrations\Db\Table\Table $table the Table to apply the action to + * @param \Migrations\Db\Table\TableMetadata $table the Table to apply the action to */ - public function __construct(Table $table) + public function __construct(TableMetadata $table) { $this->table = $table; } @@ -30,9 +30,9 @@ public function __construct(Table $table) /** * The table this action will be applied to * - * @return \Migrations\Db\Table\Table + * @return \Migrations\Db\Table\TableMetadata */ - public function getTable(): Table + public function getTable(): TableMetadata { return $this->table; } diff --git a/src/Db/Action/AddColumn.php b/src/Db/Action/AddColumn.php index 3572bb5a3..c4948740a 100644 --- a/src/Db/Action/AddColumn.php +++ b/src/Db/Action/AddColumn.php @@ -8,9 +8,8 @@ namespace Migrations\Db\Action; -use Migrations\Db\Literal; use Migrations\Db\Table\Column; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; class AddColumn extends Action { @@ -24,10 +23,10 @@ class AddColumn extends Action /** * Constructor * - * @param \Migrations\Db\Table\Table $table The table to add the column to + * @param \Migrations\Db\Table\TableMetadata $table The table to add the column to * @param \Migrations\Db\Table\Column $column The column to add */ - public function __construct(Table $table, Column $column) + public function __construct(TableMetadata $table, Column $column) { parent::__construct($table); $this->column = $column; @@ -36,13 +35,13 @@ public function __construct(Table $table, Column $column) /** * Returns a new AddColumn object after assembling the given commands * - * @param \Migrations\Db\Table\Table $table The table to add the column to + * @param \Migrations\Db\Table\TableMetadata $table The table to add the column to * @param string $columnName The column name - * @param string|\Migrations\Db\Literal $type The column type + * @param string $type The column type * @param array $options The column options * @return self */ - public static function build(Table $table, string $columnName, string|Literal $type, array $options = []): self + public static function build(TableMetadata $table, string $columnName, string $type, array $options = []): self { $column = new Column(); $column->setName($columnName); diff --git a/src/Db/Action/AddForeignKey.php b/src/Db/Action/AddForeignKey.php index b8f682b5a..a1cfa00c5 100644 --- a/src/Db/Action/AddForeignKey.php +++ b/src/Db/Action/AddForeignKey.php @@ -9,7 +9,7 @@ namespace Migrations\Db\Action; use Migrations\Db\Table\ForeignKey; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; class AddForeignKey extends Action { @@ -23,10 +23,10 @@ class AddForeignKey extends Action /** * Constructor * - * @param \Migrations\Db\Table\Table $table The table to add the foreign key to + * @param \Migrations\Db\Table\TableMetadata $table The table to add the foreign key to * @param \Migrations\Db\Table\ForeignKey $fk The foreign key to add */ - public function __construct(Table $table, ForeignKey $fk) + public function __construct(TableMetadata $table, ForeignKey $fk) { parent::__construct($table); $this->foreignKey = $fk; @@ -36,34 +36,44 @@ public function __construct(Table $table, ForeignKey $fk) * Creates a new AddForeignKey object after building the foreign key with * the passed attributes * - * @param \Migrations\Db\Table\Table $table The table object to add the foreign key to + * @param \Migrations\Db\Table\TableMetadata $table The table object to add the foreign key to * @param string|string[] $columns The columns for the foreign key - * @param \Migrations\Db\Table\Table|string $referencedTable The table the foreign key references + * @param \Migrations\Db\Table\TableMetadata|string $referencedTable The table the foreign key references * @param string|string[] $referencedColumns The columns in the referenced table * @param array $options Extra options for the foreign key * @param string|null $name The name of the foreign key * @return self */ - public static function build(Table $table, string|array $columns, Table|string $referencedTable, string|array $referencedColumns = ['id'], array $options = [], ?string $name = null): self - { + public static function build( + TableMetadata $table, + string|array $columns, + TableMetadata|string $referencedTable, + string|array $referencedColumns = ['id'], + array $options = [], + ?string $name = null, + ): self { if (is_string($referencedColumns)) { $referencedColumns = [$referencedColumns]; // str to array } - if (is_string($referencedTable)) { - $referencedTable = new Table($referencedTable); + if ($referencedTable instanceof TableMetadata) { + $referencedTable = $referencedTable->getName(); } - $fk = new ForeignKey(); - $fk->setReferencedTable($referencedTable) - ->setColumns($columns) - ->setReferencedColumns($referencedColumns) - ->setOptions($options); - - if ($name !== null) { - $fk->setName($name); + // Shimming old 4.x + if (isset($options['constraint'])) { + $options['name'] = $options['constraint']; + unset($options['constraint']); } + $fk = new ForeignKey( + name: $name ?? '', + columns: (array)$columns, + referencedTable: $referencedTable, + referencedColumns: $referencedColumns, + ); + $fk->setOptions($options); + return new AddForeignKey($table, $fk); } diff --git a/src/Db/Action/AddIndex.php b/src/Db/Action/AddIndex.php index 102158716..f818ed4c6 100644 --- a/src/Db/Action/AddIndex.php +++ b/src/Db/Action/AddIndex.php @@ -9,7 +9,7 @@ namespace Migrations\Db\Action; use Migrations\Db\Table\Index; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; class AddIndex extends Action { @@ -23,10 +23,10 @@ class AddIndex extends Action /** * Constructor * - * @param \Migrations\Db\Table\Table $table The table to add the index to + * @param \Migrations\Db\Table\TableMetadata $table The table to add the index to * @param \Migrations\Db\Table\Index $index The index to be added */ - public function __construct(Table $table, Index $index) + public function __construct(TableMetadata $table, Index $index) { parent::__construct($table); $this->index = $index; @@ -36,12 +36,12 @@ public function __construct(Table $table, Index $index) * Creates a new AddIndex object after building the index object with the * provided arguments * - * @param \Migrations\Db\Table\Table $table The table to add the index to + * @param \Migrations\Db\Table\TableMetadata $table The table to add the index to * @param string|string[]|\Migrations\Db\Table\Index $columns The columns to index * @param array $options Additional options for the index creation * @return self */ - public static function build(Table $table, string|array|Index $columns, array $options = []): self + public static function build(TableMetadata $table, string|array|Index $columns, array $options = []): self { // create a new index object if strings or an array of strings were supplied if (!($columns instanceof Index)) { diff --git a/src/Db/Action/AddPartition.php b/src/Db/Action/AddPartition.php new file mode 100644 index 000000000..aeb0a0bdc --- /dev/null +++ b/src/Db/Action/AddPartition.php @@ -0,0 +1,45 @@ +partition = $partition; + } + + /** + * Returns the partition definition to add + * + * @return \Migrations\Db\Table\PartitionDefinition + */ + public function getPartition(): PartitionDefinition + { + return $this->partition; + } +} diff --git a/src/Db/Action/ChangeColumn.php b/src/Db/Action/ChangeColumn.php index 63327890f..267e30aa9 100644 --- a/src/Db/Action/ChangeColumn.php +++ b/src/Db/Action/ChangeColumn.php @@ -8,9 +8,8 @@ namespace Migrations\Db\Action; -use Migrations\Db\Literal; use Migrations\Db\Table\Column; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; class ChangeColumn extends Action { @@ -31,11 +30,11 @@ class ChangeColumn extends Action /** * Constructor * - * @param \Migrations\Db\Table\Table $table The table to alter + * @param \Migrations\Db\Table\TableMetadata $table The table to alter * @param string $columnName The name of the column to change * @param \Migrations\Db\Table\Column $column The column definition */ - public function __construct(Table $table, string $columnName, Column $column) + public function __construct(TableMetadata $table, string $columnName, Column $column) { parent::__construct($table); $this->columnName = $columnName; @@ -51,13 +50,13 @@ public function __construct(Table $table, string $columnName, Column $column) * Creates a new ChangeColumn object after building the column definition * out of the provided arguments * - * @param \Migrations\Db\Table\Table $table The table to alter + * @param \Migrations\Db\Table\TableMetadata $table The table to alter * @param string $columnName The name of the column to change - * @param string|\Migrations\Db\Literal $type The type of the column + * @param string $type The type of the column * @param array $options Additional options for the column * @return self */ - public static function build(Table $table, string $columnName, string|Literal $type, array $options = []): self + public static function build(TableMetadata $table, string $columnName, string $type, array $options = []): self { $column = new Column(); $column->setName($columnName); diff --git a/src/Db/Action/ChangeComment.php b/src/Db/Action/ChangeComment.php index b483fa3cb..0fb773c90 100644 --- a/src/Db/Action/ChangeComment.php +++ b/src/Db/Action/ChangeComment.php @@ -8,7 +8,7 @@ namespace Migrations\Db\Action; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; class ChangeComment extends Action { @@ -22,10 +22,10 @@ class ChangeComment extends Action /** * Constructor * - * @param \Migrations\Db\Table\Table $table The table to be changed + * @param \Migrations\Db\Table\TableMetadata $table The table to be changed * @param string|null $newComment The new comment for the table */ - public function __construct(Table $table, ?string $newComment) + public function __construct(TableMetadata $table, ?string $newComment) { parent::__construct($table); $this->newComment = $newComment; diff --git a/src/Db/Action/ChangePrimaryKey.php b/src/Db/Action/ChangePrimaryKey.php index 760f7fab2..332526f3b 100644 --- a/src/Db/Action/ChangePrimaryKey.php +++ b/src/Db/Action/ChangePrimaryKey.php @@ -8,7 +8,7 @@ namespace Migrations\Db\Action; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; class ChangePrimaryKey extends Action { @@ -22,10 +22,10 @@ class ChangePrimaryKey extends Action /** * Constructor * - * @param \Migrations\Db\Table\Table $table The table to be changed + * @param \Migrations\Db\Table\TableMetadata $table The table to be changed * @param string|string[]|null $newColumns The new columns for the primary key */ - public function __construct(Table $table, string|array|null $newColumns) + public function __construct(TableMetadata $table, string|array|null $newColumns) { parent::__construct($table); $this->newColumns = $newColumns; diff --git a/src/Db/Action/DropForeignKey.php b/src/Db/Action/DropForeignKey.php index 3a4a00e90..c311f1bc8 100644 --- a/src/Db/Action/DropForeignKey.php +++ b/src/Db/Action/DropForeignKey.php @@ -9,7 +9,7 @@ namespace Migrations\Db\Action; use Migrations\Db\Table\ForeignKey; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; class DropForeignKey extends Action { @@ -23,10 +23,10 @@ class DropForeignKey extends Action /** * Constructor * - * @param \Migrations\Db\Table\Table $table The table to remove the constraint from + * @param \Migrations\Db\Table\TableMetadata $table The table to remove the constraint from * @param \Migrations\Db\Table\ForeignKey $foreignKey The foreign key to remove */ - public function __construct(Table $table, ForeignKey $foreignKey) + public function __construct(TableMetadata $table, ForeignKey $foreignKey) { parent::__construct($table); $this->foreignKey = $foreignKey; @@ -36,12 +36,12 @@ public function __construct(Table $table, ForeignKey $foreignKey) * Creates a new DropForeignKey object after building the ForeignKey * definition out of the passed arguments. * - * @param \Migrations\Db\Table\Table $table The table to delete the foreign key from + * @param \Migrations\Db\Table\TableMetadata $table The table to delete the foreign key from * @param string|string[] $columns The columns participating in the foreign key * @param string|null $constraint The constraint name * @return self */ - public static function build(Table $table, string|array $columns, ?string $constraint = null): self + public static function build(TableMetadata $table, string|array $columns, ?string $constraint = null): self { if (is_string($columns)) { $columns = [$columns]; diff --git a/src/Db/Action/DropIndex.php b/src/Db/Action/DropIndex.php index eef579aa3..4c9bbf014 100644 --- a/src/Db/Action/DropIndex.php +++ b/src/Db/Action/DropIndex.php @@ -9,7 +9,7 @@ namespace Migrations\Db\Action; use Migrations\Db\Table\Index; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; class DropIndex extends Action { @@ -23,10 +23,10 @@ class DropIndex extends Action /** * Constructor * - * @param \Migrations\Db\Table\Table $table The table owning the index + * @param \Migrations\Db\Table\TableMetadata $table The table owning the index * @param \Migrations\Db\Table\Index $index The index to be dropped */ - public function __construct(Table $table, Index $index) + public function __construct(TableMetadata $table, Index $index) { parent::__construct($table); $this->index = $index; @@ -36,11 +36,11 @@ public function __construct(Table $table, Index $index) * Creates a new DropIndex object after assembling the passed * arguments. * - * @param \Migrations\Db\Table\Table $table The table where the index is + * @param \Migrations\Db\Table\TableMetadata $table The table where the index is * @param string[] $columns the indexed columns * @return self */ - public static function build(Table $table, array $columns = []): self + public static function build(TableMetadata $table, array $columns = []): self { $index = new Index(); $index->setColumns($columns); @@ -52,11 +52,11 @@ public static function build(Table $table, array $columns = []): self * Creates a new DropIndex when the name of the index to drop * is known. * - * @param \Migrations\Db\Table\Table $table The table where the index is + * @param \Migrations\Db\Table\TableMetadata $table The table where the index is * @param string $name The name of the index * @return self */ - public static function buildFromName(Table $table, string $name): self + public static function buildFromName(TableMetadata $table, string $name): self { $index = new Index(); $index->setName($name); diff --git a/src/Db/Action/DropPartition.php b/src/Db/Action/DropPartition.php new file mode 100644 index 000000000..3647ff47d --- /dev/null +++ b/src/Db/Action/DropPartition.php @@ -0,0 +1,44 @@ +partitionName = $partitionName; + } + + /** + * Returns the partition name to drop + * + * @return string + */ + public function getPartitionName(): string + { + return $this->partitionName; + } +} diff --git a/src/Db/Action/RemoveColumn.php b/src/Db/Action/RemoveColumn.php index 30307570c..5aaa4a253 100644 --- a/src/Db/Action/RemoveColumn.php +++ b/src/Db/Action/RemoveColumn.php @@ -9,7 +9,7 @@ namespace Migrations\Db\Action; use Migrations\Db\Table\Column; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; class RemoveColumn extends Action { @@ -23,10 +23,10 @@ class RemoveColumn extends Action /** * Constructor * - * @param \Migrations\Db\Table\Table $table The table where the column is + * @param \Migrations\Db\Table\TableMetadata $table The table where the column is * @param \Migrations\Db\Table\Column $column The column to be removed */ - public function __construct(Table $table, Column $column) + public function __construct(TableMetadata $table, Column $column) { parent::__construct($table); $this->column = $column; @@ -36,11 +36,11 @@ public function __construct(Table $table, Column $column) * Creates a new RemoveColumn object after assembling the * passed arguments. * - * @param \Migrations\Db\Table\Table $table The table where the column is + * @param \Migrations\Db\Table\TableMetadata $table The table where the column is * @param string $columnName The name of the column to drop * @return self */ - public static function build(Table $table, string $columnName): self + public static function build(TableMetadata $table, string $columnName): self { $column = new Column(); $column->setName($columnName); diff --git a/src/Db/Action/RenameColumn.php b/src/Db/Action/RenameColumn.php index c2b342749..2565d753d 100644 --- a/src/Db/Action/RenameColumn.php +++ b/src/Db/Action/RenameColumn.php @@ -9,7 +9,7 @@ namespace Migrations\Db\Action; use Migrations\Db\Table\Column; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; class RenameColumn extends Action { @@ -30,11 +30,11 @@ class RenameColumn extends Action /** * Constructor * - * @param \Migrations\Db\Table\Table $table The table where the column is + * @param \Migrations\Db\Table\TableMetadata $table The table where the column is * @param \Migrations\Db\Table\Column $column The column to be renamed * @param string $newName The new name for the column */ - public function __construct(Table $table, Column $column, string $newName) + public function __construct(TableMetadata $table, Column $column, string $newName) { parent::__construct($table); $this->newName = $newName; @@ -45,12 +45,12 @@ public function __construct(Table $table, Column $column, string $newName) * Creates a new RenameColumn object after building the passed * arguments * - * @param \Migrations\Db\Table\Table $table The table where the column is + * @param \Migrations\Db\Table\TableMetadata $table The table where the column is * @param string $columnName The name of the column to be changed * @param string $newName The new name for the column * @return self */ - public static function build(Table $table, string $columnName, string $newName): self + public static function build(TableMetadata $table, string $columnName, string $newName): self { $column = new Column(); $column->setName($columnName); diff --git a/src/Db/Action/RenameTable.php b/src/Db/Action/RenameTable.php index 9808c3119..1f0b11e63 100644 --- a/src/Db/Action/RenameTable.php +++ b/src/Db/Action/RenameTable.php @@ -8,7 +8,7 @@ namespace Migrations\Db\Action; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; class RenameTable extends Action { @@ -22,10 +22,10 @@ class RenameTable extends Action /** * Constructor * - * @param \Migrations\Db\Table\Table $table The table to be renamed + * @param \Migrations\Db\Table\TableMetadata $table The table to be renamed * @param string $newName The new name for the table */ - public function __construct(Table $table, string $newName) + public function __construct(TableMetadata $table, string $newName) { parent::__construct($table); $this->newName = $newName; diff --git a/src/Db/Action/SetPartitioning.php b/src/Db/Action/SetPartitioning.php new file mode 100644 index 000000000..0e24e048a --- /dev/null +++ b/src/Db/Action/SetPartitioning.php @@ -0,0 +1,45 @@ +partition = $partition; + } + + /** + * Returns the partition configuration + * + * @return \Migrations\Db\Table\Partition + */ + public function getPartition(): Partition + { + return $this->partition; + } +} diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index f78453a63..3bb4a81b6 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -10,6 +10,7 @@ use BadMethodCallException; use Cake\Console\ConsoleIo; +use Cake\Core\Configure; use Cake\Database\Connection; use Cake\Database\Query; use Cake\Database\Query\DeleteQuery; @@ -25,29 +26,32 @@ use Migrations\Db\Action\AddColumn; use Migrations\Db\Action\AddForeignKey; use Migrations\Db\Action\AddIndex; +use Migrations\Db\Action\AddPartition; use Migrations\Db\Action\ChangeColumn; use Migrations\Db\Action\ChangeComment; use Migrations\Db\Action\ChangePrimaryKey; use Migrations\Db\Action\DropForeignKey; use Migrations\Db\Action\DropIndex; +use Migrations\Db\Action\DropPartition; use Migrations\Db\Action\DropTable; use Migrations\Db\Action\RemoveColumn; use Migrations\Db\Action\RenameColumn; use Migrations\Db\Action\RenameTable; +use Migrations\Db\Action\SetPartitioning; use Migrations\Db\AlterInstructions; +use Migrations\Db\InsertMode; use Migrations\Db\Literal; use Migrations\Db\Table; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; -use Migrations\Db\Table\Table as TableMetadata; +use Migrations\Db\Table\Partition; +use Migrations\Db\Table\TableMetadata; use Migrations\MigrationInterface; -use Migrations\Shim\OutputAdapter; +use Migrations\SeedInterface; use PDOException; -use Phinx\Util\Literal as PhinxLiteral; use RuntimeException; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use function Cake\Core\deprecationWarning; /** @@ -75,6 +79,11 @@ abstract class AbstractAdapter implements AdapterInterface, DirectActionInterfac */ protected string $schemaTableName = 'phinxlog'; + /** + * @var string + */ + protected string $seedSchemaTableName = 'cake_seeds'; + /** * @var array */ @@ -110,6 +119,10 @@ public function setOptions(array $options): AdapterInterface $this->setSchemaTableName($options['migration_table']); } + if (isset($options['seed_table'])) { + $this->setSeedSchemaTableName($options['seed_table']); + } + if (isset($options['connection']) && $options['connection'] instanceof Connection) { $this->setConnection($options['connection']); } @@ -131,21 +144,7 @@ public function setConnection(Connection $connection): AdapterInterface if (!$this->hasTable($this->getSchemaTableName())) { $this->createSchemaTable(); } else { - $table = new Table($this->getSchemaTableName(), [], $this); - if (!$table->hasColumn('migration_name')) { - $table - ->addColumn( - 'migration_name', - 'string', - ['limit' => 100, 'after' => 'version', 'default' => null, 'null' => true], - ) - ->save(); - } - if (!$table->hasColumn('breakpoint')) { - $table - ->addColumn('breakpoint', 'boolean', ['default' => false, 'null' => false]) - ->save(); - } + $this->migrationsTable()->upgradeTable(); } return $this; @@ -307,56 +306,55 @@ protected function verboseLog(string $message): void } /** - * @inheritDoc + * Gets the schema table name. + * + * Returns the appropriate table name based on configuration: + * - 'cake_migrations' for unified mode + * - Phinxlog table name for backwards compatibility mode + * + * @return string */ - public function setInput(InputInterface $input): AdapterInterface + public function getSchemaTableName(): string { - throw new RuntimeException('Using setInput() interface is not supported.'); - } + if ($this->isUsingUnifiedTable()) { + return UnifiedMigrationsTableStorage::TABLE_NAME; + } - /** - * @inheritDoc - */ - public function getInput(): ?InputInterface - { - throw new RuntimeException('Using getInput() interface is not supported.'); + return $this->schemaTableName; } /** - * @inheritDoc + * Sets the schema table name. + * + * @param string $schemaTableName Schema Table Name + * @return $this */ - public function setOutput(OutputInterface $output): AdapterInterface + public function setSchemaTableName(string $schemaTableName) { - throw new RuntimeException('Using setInput() method is not supported'); - } + $this->schemaTableName = $schemaTableName; - /** - * @inheritDoc - */ - public function getOutput(): OutputInterface - { - return new OutputAdapter($this->io); + return $this; } /** - * Gets the schema table name. + * Gets the seed schema table name. * * @return string */ - public function getSchemaTableName(): string + public function getSeedSchemaTableName(): string { - return $this->schemaTableName; + return $this->seedSchemaTableName; } /** - * Sets the schema table name. + * Sets the seed schema table name. * - * @param string $schemaTableName Schema Table Name + * @param string $seedSchemaTableName Seed Schema Table Name * @return $this */ - public function setSchemaTableName(string $schemaTableName) + public function setSeedSchemaTableName(string $seedSchemaTableName) { - $this->schemaTableName = $schemaTableName; + $this->seedSchemaTableName = $seedSchemaTableName; return $this; } @@ -374,29 +372,40 @@ public function getColumnForType(string $columnName, string $type, array $option return $column; } + /** + * @inheritDoc + */ + public function hasColumn(string $tableName, string $columnName): bool + { + $dialect = $this->getSchemaDialect(); + + return $dialect->hasColumn($tableName, $columnName); + } + /** * @inheritDoc * @throws \InvalidArgumentException * @return void */ public function createSchemaTable(): void + { + $this->migrationsTable()->createTable(); + } + + /** + * @inheritDoc + */ + public function createSeedSchemaTable(): void { try { - $options = [ - 'id' => false, - 'primary_key' => 'version', - ]; - - $table = new Table($this->getSchemaTableName(), $options, $this); - $table->addColumn('version', 'biginteger', ['null' => false]) - ->addColumn('migration_name', 'string', ['limit' => 100, 'default' => null, 'null' => true]) - ->addColumn('start_time', 'timestamp', ['default' => null, 'null' => true]) - ->addColumn('end_time', 'timestamp', ['default' => null, 'null' => true]) - ->addColumn('breakpoint', 'boolean', ['default' => false, 'null' => false]) + $table = new Table($this->getSeedSchemaTableName(), [], $this); + $table->addColumn('plugin', 'string', ['limit' => 100, 'default' => null, 'null' => true]) + ->addColumn('seed_name', 'string', ['limit' => 100, 'null' => false]) + ->addColumn('executed_at', 'timestamp', ['default' => null, 'null' => true]) ->save(); } catch (Exception $exception) { throw new InvalidArgumentException( - 'There was a problem creating the schema table: ' . $exception->getMessage(), + 'There was a problem creating the seed schema table: ' . $exception->getMessage(), (int)$exception->getCode(), $exception, ); @@ -416,7 +425,7 @@ public function getAdapterType(): string */ public function isValidColumnType(Column $column): bool { - return $column->getType() instanceof Literal || in_array($column->getType(), $this->getColumnTypes(), true); + return in_array($column->getType(), $this->getColumnTypes(), true); } /** @@ -506,6 +515,24 @@ protected function hasCreatedTable(string $tableName): bool return in_array($tableName, $this->createdTables, true); } + /** + * Execute a Query object. Handles logging and dry-run modes. + * + * @param \Cake\Database\Query $query The query to execute + * @return int The number of affected rows. + */ + public function executeQuery(Query $query): int + { + $this->verboseLog($query->sql()); + + if ($this->isDryRunEnabled()) { + return 0; + } + $stmt = $query->execute(); + + return $stmt->rowCount(); + } + /** * @inheritDoc */ @@ -607,9 +634,14 @@ public function fetchAll(string $sql): array /** * @inheritDoc */ - public function insert(TableMetadata $table, array $row): void - { - $sql = $this->generateInsertSql($table, $row); + public function insert( + TableMetadata $table, + array $row, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): void { + $sql = $this->generateInsertSql($table, $row, $mode, $updateColumns, $conflictColumns); if ($this->isDryRunEnabled()) { $this->io->out($sql); @@ -617,7 +649,7 @@ public function insert(TableMetadata $table, array $row): void $vals = []; foreach ($row as $value) { $placeholder = '?'; - if ($value instanceof Literal || $value instanceof PhinxLiteral) { + if ($value instanceof Literal) { $placeholder = (string)$value; } if ($placeholder === '?') { @@ -631,14 +663,23 @@ public function insert(TableMetadata $table, array $row): void /** * Generates the SQL for an insert. * - * @param \Migrations\Db\Table\Table $table The table to insert into + * @param \Migrations\Db\Table\TableMetadata $table The table to insert into * @param array $row The row to insert + * @param \Migrations\Db\InsertMode|null $mode Insert mode + * @param array|null $updateColumns Columns to update on upsert conflict + * @param array|null $conflictColumns Columns that define uniqueness for upsert (unused in MySQL) * @return string */ - protected function generateInsertSql(TableMetadata $table, array $row): string - { + protected function generateInsertSql( + TableMetadata $table, + array $row, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): string { $sql = sprintf( - 'INSERT INTO %s ', + '%s INTO %s ', + $this->getInsertPrefix($mode), $this->quoteTableName($table->getName()), ); $columns = array_keys($row); @@ -650,25 +691,77 @@ protected function generateInsertSql(TableMetadata $table, array $row): string } } + $upsertClause = $this->getUpsertClause($mode, $updateColumns, $conflictColumns); + if ($this->isDryRunEnabled()) { - $sql .= ' VALUES (' . implode(', ', array_map($this->quoteValue(...), $row)) . ');'; + $sql .= ' VALUES (' . implode(', ', array_map($this->quoteValue(...), $row)) . ')' . $upsertClause . ';'; return $sql; } else { $values = []; foreach ($row as $value) { $placeholder = '?'; - if ($value instanceof Literal || $value instanceof PhinxLiteral) { + if ($value instanceof Literal) { $placeholder = (string)$value; } $values[] = $placeholder; } - $sql .= ' VALUES (' . implode(',', $values) . ')'; + $sql .= ' VALUES (' . implode(',', $values) . ')' . $upsertClause; return $sql; } } + /** + * Get the INSERT prefix based on insert mode and database type. + * + * @param \Migrations\Db\InsertMode|null $mode Insert mode + * @return string + */ + protected function getInsertPrefix(?InsertMode $mode = null): string + { + if ($mode === InsertMode::IGNORE) { + return 'INSERT IGNORE'; + } + + return 'INSERT'; + } + + /** + * Get the upsert clause for MySQL (ON DUPLICATE KEY UPDATE). + * + * MySQL's ON DUPLICATE KEY UPDATE applies to all unique key constraints on the table, + * so the $conflictColumns parameter is not used. If you pass conflictColumns when using + * MySQL, a warning will be triggered. + * + * @param \Migrations\Db\InsertMode|null $mode Insert mode + * @param array|null $updateColumns Columns to update on conflict + * @param array|null $conflictColumns Columns that define uniqueness (unused in MySQL) + * @return string + */ + protected function getUpsertClause(?InsertMode $mode, ?array $updateColumns, ?array $conflictColumns = null): string + { + if ($mode !== InsertMode::UPSERT || $updateColumns === null) { + return ''; + } + + if ($conflictColumns !== null) { + trigger_error( + 'The $conflictColumns parameter is ignored by MySQL. ' . + 'MySQL\'s ON DUPLICATE KEY UPDATE applies to all unique constraints on the table.', + E_USER_WARNING, + ); + } + + $updates = []; + foreach ($updateColumns as $column) { + $quotedColumn = $this->quoteColumnName($column); + $updates[] = $quotedColumn . ' = VALUES(' . $quotedColumn . ')'; + } + + return ' ON DUPLICATE KEY UPDATE ' . implode(', ', $updates); + } + /** * Quotes a database value. * @@ -685,16 +778,14 @@ protected function quoteValue(mixed $value): mixed return 'null'; } - if ($value instanceof Literal || $value instanceof PhinxLiteral) { + if ($value instanceof Literal) { return (string)$value; } if ($value instanceof DateTime) { - return $value->toDateTimeString(); - } - - if ($value instanceof Date) { - return $value->toDateString(); + $value = $value->toDateTimeString(); + } elseif ($value instanceof Date) { + $value = $value->toDateString(); } $driver = $this->getConnection()->getDriver(); @@ -718,9 +809,14 @@ protected function quoteString(string $value): string /** * @inheritDoc */ - public function bulkinsert(TableMetadata $table, array $rows): void - { - $sql = $this->generateBulkInsertSql($table, $rows); + public function bulkinsert( + TableMetadata $table, + array $rows, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): void { + $sql = $this->generateBulkInsertSql($table, $rows, $mode, $updateColumns, $conflictColumns); if ($this->isDryRunEnabled()) { $this->io->out($sql); @@ -729,10 +825,10 @@ public function bulkinsert(TableMetadata $table, array $rows): void foreach ($rows as $row) { foreach ($row as $v) { $placeholder = '?'; - if ($v instanceof Literal || $v instanceof PhinxLiteral) { + if ($v instanceof Literal) { $placeholder = (string)$v; } - if ($placeholder == '?') { + if ($placeholder === '?') { if ($v instanceof DateTime) { $vals[] = $v->toDateTimeString(); } elseif ($v instanceof Date) { @@ -752,14 +848,23 @@ public function bulkinsert(TableMetadata $table, array $rows): void /** * Generates the SQL for a bulk insert. * - * @param \Migrations\Db\Table\Table $table The table to insert into + * @param \Migrations\Db\Table\TableMetadata $table The table to insert into * @param array $rows The rows to insert + * @param \Migrations\Db\InsertMode|null $mode Insert mode + * @param array|null $updateColumns Columns to update on upsert conflict + * @param array|null $conflictColumns Columns that define uniqueness for upsert (unused in MySQL) * @return string */ - protected function generateBulkInsertSql(TableMetadata $table, array $rows): string - { + protected function generateBulkInsertSql( + TableMetadata $table, + array $rows, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): string { $sql = sprintf( - 'INSERT INTO %s ', + '%s INTO %s ', + $this->getInsertPrefix($mode), $this->quoteTableName($table->getName()), ); $current = current($rows); @@ -767,11 +872,13 @@ protected function generateBulkInsertSql(TableMetadata $table, array $rows): str $sql .= '(' . implode(', ', array_map($this->quoteColumnName(...), $keys)) . ') VALUES '; + $upsertClause = $this->getUpsertClause($mode, $updateColumns, $conflictColumns); + if ($this->isDryRunEnabled()) { $values = array_map(function ($row) { return '(' . implode(', ', array_map($this->quoteValue(...), $row)) . ')'; }, $rows); - $sql .= implode(', ', $values) . ';'; + $sql .= implode(', ', $values) . $upsertClause . ';'; return $sql; } else { @@ -780,7 +887,7 @@ protected function generateBulkInsertSql(TableMetadata $table, array $rows): str $values = []; foreach ($row as $v) { $placeholder = '?'; - if ($v instanceof Literal || $v instanceof PhinxLiteral) { + if ($v instanceof Literal) { $placeholder = (string)$v; } $values[] = $placeholder; @@ -788,7 +895,7 @@ protected function generateBulkInsertSql(TableMetadata $table, array $rows): str $query = '(' . implode(', ', $values) . ')'; $queries[] = $query; } - $sql .= implode(',', $queries); + $sql .= implode(',', $queries) . $upsertClause; return $sql; } @@ -804,6 +911,75 @@ public function getVersions(): array return array_keys($rows); } + /** + * @inheritDoc + */ + public function cleanupMissing(array $missingVersions): void + { + $storage = $this->migrationsTable(); + + $storage->cleanupMissing($missingVersions); + } + + /** + * Get the migrations table storage implementation. + * + * Returns either UnifiedMigrationsTableStorage (new cake_migrations table) + * or MigrationsTableStorage (legacy phinxlog tables) based on configuration + * and autodetection. + * + * @return \Migrations\Db\Adapter\MigrationsTableStorage|\Migrations\Db\Adapter\UnifiedMigrationsTableStorage + * @internal + */ + protected function migrationsTable(): MigrationsTableStorage|UnifiedMigrationsTableStorage + { + if ($this->isUsingUnifiedTable()) { + return new UnifiedMigrationsTableStorage( + $this, + $this->getOption('plugin'), + ); + } + + return new MigrationsTableStorage( + $this, + $this->getSchemaTableName(), + $this->getOption('plugin'), + ); + } + + /** + * Determine if using the unified cake_migrations table. + * + * Checks configuration and autodetects based on existing legacy tables. + * + * @return bool True if using unified table, false for legacy phinxlog tables + */ + protected function isUsingUnifiedTable(): bool + { + $config = Configure::read('Migrations.legacyTables'); + + // Explicit configuration takes precedence + if ($config === false) { + return true; + } + + if ($config === true) { + return false; + } + + // Autodetect mode (config is null or not set) + // Check if the main legacy phinxlog table exists + if ($this->connection !== null) { + $dialect = $this->connection->getDriver()->schemaDialect(); + if ($dialect->hasTable('phinxlog')) { + return false; + } + } + + // No legacy phinxlog table found - use unified table + return true; + } + /** * {@inheritDoc} * @@ -811,23 +987,22 @@ public function getVersions(): array */ public function getVersionLog(): array { - $result = []; - switch ($this->options['version_order']) { case Config::VERSION_ORDER_CREATION_TIME: - $orderBy = 'version ASC'; + $orderBy = ['version' => 'ASC']; break; case Config::VERSION_ORDER_EXECUTION_TIME: - $orderBy = 'start_time ASC, version ASC'; + $orderBy = ['start_time' => 'ASC', 'version' => 'ASC']; break; default: throw new RuntimeException('Invalid version_order configuration option'); } + $query = $this->migrationsTable()->getVersions($orderBy); // This will throw an exception if doing a --dry-run without any migrations as phinxlog // does not exist, so in that case, we can just expect to trivially return empty set try { - $rows = $this->fetchAll(sprintf('SELECT * FROM %s ORDER BY %s', $this->quoteTableName($this->getSchemaTableName()), $orderBy)); + $rows = $query->execute()->fetchAll('assoc'); } catch (PDOException $e) { if (!$this->isDryRunEnabled()) { throw $e; @@ -835,6 +1010,7 @@ public function getVersionLog(): array $rows = []; } + $result = []; foreach ($rows as $version) { $result[(int)$version['version']] = $version; } @@ -848,35 +1024,10 @@ public function getVersionLog(): array public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime): AdapterInterface { if (strcasecmp($direction, MigrationInterface::UP) === 0) { - // up - $sql = sprintf( - 'INSERT INTO %s (%s, %s, %s, %s, %s) VALUES (?, ?, ?, ?, ?);', - $this->quoteTableName($this->getSchemaTableName()), - $this->quoteColumnName('version'), - $this->quoteColumnName('migration_name'), - $this->quoteColumnName('start_time'), - $this->quoteColumnName('end_time'), - $this->quoteColumnName('breakpoint'), - ); - $params = [ - $migration->getVersion(), - substr($migration->getName(), 0, 100), - $startTime, - $endTime, - $this->castToBool(false), - ]; - - $this->execute($sql, $params); + $this->migrationsTable()->recordUp($migration, $startTime, $endTime); } else { // down - $sql = sprintf( - 'DELETE FROM %s WHERE %s = ?', - $this->quoteTableName($this->getSchemaTableName()), - $this->quoteColumnName('version'), - ); - $params = [$migration->getVersion()]; - - $this->execute($sql, $params); + $this->migrationsTable()->recordDown($migration); } return $this; @@ -887,19 +1038,7 @@ public function migrated(MigrationInterface $migration, string $direction, strin */ public function toggleBreakpoint(MigrationInterface $migration): AdapterInterface { - $params = [ - $migration->getVersion(), - ]; - $this->query( - sprintf( - 'UPDATE %1$s SET %2$s = CASE %2$s WHEN true THEN false ELSE true END, %4$s = %4$s WHERE %3$s = ?;', - $this->quoteTableName($this->getSchemaTableName()), - $this->quoteColumnName('breakpoint'), - $this->quoteColumnName('version'), - $this->quoteColumnName('start_time'), - ), - $params, - ); + $this->migrationsTable()->toggleBreakpoint($migration); return $this; } @@ -909,15 +1048,7 @@ public function toggleBreakpoint(MigrationInterface $migration): AdapterInterfac */ public function resetAllBreakpoints(): int { - return $this->execute( - sprintf( - 'UPDATE %1$s SET %2$s = %3$s, %4$s = %4$s WHERE %2$s <> %3$s;', - $this->quoteTableName($this->getSchemaTableName()), - $this->quoteColumnName('breakpoint'), - $this->castToBool(false), - $this->quoteColumnName('start_time'), - ), - ); + return $this->migrationsTable()->resetAllBreakpoints(); } /** @@ -949,20 +1080,90 @@ public function unsetBreakpoint(MigrationInterface $migration): AdapterInterface */ protected function markBreakpoint(MigrationInterface $migration, bool $state): AdapterInterface { - $params = [ - $this->castToBool($state), - $migration->getVersion(), - ]; - $this->query( - sprintf( - 'UPDATE %1$s SET %2$s = ?, %3$s = %3$s WHERE %4$s = ?;', - $this->quoteTableName($this->getSchemaTableName()), - $this->quoteColumnName('breakpoint'), - $this->quoteColumnName('start_time'), - $this->quoteColumnName('version'), - ), - $params, - ); + $this->migrationsTable()->markBreakpoint($migration, $state); + + return $this; + } + + /** + * @inheritDoc + */ + public function getSeedLog(): array + { + $query = $this->getSelectBuilder(); + $query->select('*') + ->from($this->getSeedSchemaTableName()) + ->orderBy(['executed_at' => 'ASC', 'id' => 'ASC']); + + try { + $rows = $query->execute()->fetchAll('assoc'); + } catch (PDOException $e) { + if (!$this->isDryRunEnabled()) { + throw $e; + } + $rows = []; + } + + return $rows; + } + + /** + * @inheritDoc + */ + public function seedExecuted(SeedInterface $seed, string $executedTime): AdapterInterface + { + $plugin = null; + $className = get_class($seed); + + if (str_contains($className, '\\')) { + $parts = explode('\\', $className); + $appNamespace = Configure::read('App.namespace', 'App'); + if (count($parts) > 1 && $parts[0] !== $appNamespace) { + $plugin = $parts[0]; + } + } + + $seedName = substr($seed->getName(), 0, 100); + + $query = $this->getInsertBuilder(); + $query->insert(['plugin', 'seed_name', 'executed_at']) + ->into($this->getSeedSchemaTableName()) + ->values([ + 'plugin' => $plugin, + 'seed_name' => $seedName, + 'executed_at' => $executedTime, + ]); + $this->executeQuery($query); + + return $this; + } + + /** + * @inheritDoc + */ + public function removeSeedFromLog(SeedInterface $seed): AdapterInterface + { + $plugin = null; + $className = get_class($seed); + + if (str_contains($className, '\\')) { + $parts = explode('\\', $className); + $appNamespace = Configure::read('App.namespace', 'App'); + if (count($parts) > 1 && $parts[0] !== $appNamespace) { + $plugin = $parts[0]; + } + } + + $seedName = $seed->getName(); + + $query = $this->getDeleteBuilder(); + $query->delete() + ->from($this->getSeedSchemaTableName()) + ->where([ + 'seed_name' => $seedName, + 'plugin IS' => $plugin, + ]); + $this->executeQuery($query); return $this; } @@ -1043,26 +1244,37 @@ public function castToBool($value): mixed */ protected function getDefaultValueDefinition(mixed $default, ?string $columnType = null): string { - $datetimeTypes = [ - static::PHINX_TYPE_DATETIME, - static::PHINX_TYPE_TIMESTAMP, - static::PHINX_TYPE_TIME, - static::PHINX_TYPE_DATE, + // SQL functions mapped to their valid column types (ordered longest-first to avoid prefix conflicts) + $sqlFunctionTypes = [ + 'CURRENT_TIMESTAMP' => [static::TYPE_DATETIME, static::TYPE_TIMESTAMP, static::TYPE_TIME, static::TYPE_DATE], + 'CURRENT_DATE' => [static::TYPE_DATE], + 'CURRENT_TIME' => [static::TYPE_TIME], ]; if ($default instanceof Literal) { $default = (string)$default; - } elseif (is_string($default) && stripos($default, 'CURRENT_TIMESTAMP') === 0) { - // Only skip quoting CURRENT_TIMESTAMP for datetime-related column types. - // For other types (like string), it should be quoted as a literal string value. - if (!in_array($columnType, $datetimeTypes, true)) { + } elseif (is_string($default) && $columnType !== null) { + $matched = false; + foreach ($sqlFunctionTypes as $function => $validTypes) { + // Match function name at start, followed by end of string or opening parenthesis + $len = strlen($function); + if ( + stripos($default, $function) === 0 && + (strlen($default) === $len || $default[$len] === '(') && + in_array($columnType, $validTypes, true) + ) { + $matched = true; + break; + } + } + if (!$matched) { $default = $this->quoteString($default); } } elseif (is_string($default)) { $default = $this->quoteString($default); } elseif (is_bool($default)) { $default = $this->castToBool($default); - } elseif ($default !== null && $columnType === static::PHINX_TYPE_BOOLEAN) { + } elseif ($default !== null && $columnType === static::TYPE_BOOLEAN) { $default = $this->castToBool((bool)$default); } @@ -1094,7 +1306,7 @@ public function addColumn(TableMetadata $table, Column $column): void /** * Returns the instructions to add the specified column to a database table. * - * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\TableMetadata $table Table * @param \Migrations\Db\Table\Column $column Column * @return \Migrations\Db\AlterInstructions */ @@ -1168,7 +1380,7 @@ public function addIndex(TableMetadata $table, Index $index): void /** * Returns the instructions to add the specified index to a database table. * - * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\TableMetadata $table Table * @param \Migrations\Db\Table\Index $index Index * @return \Migrations\Db\AlterInstructions */ @@ -1210,6 +1422,27 @@ public function dropIndexByName(string $tableName, string $indexName): void */ abstract protected function getDropIndexByNameInstructions(string $tableName, string $indexName): AlterInstructions; + /** + * @inheritDoc + */ + public function hasIndex(string $tableName, string|array $columns): bool + { + $dialect = $this->getSchemaDialect(); + $columns = is_array($columns) ? $columns : [$columns]; + + return $dialect->hasIndex($tableName, $columns); + } + + /** + * @inheritDoc + */ + public function hasIndexByName(string $tableName, string $indexName): bool + { + $dialect = $this->getSchemaDialect(); + + return $dialect->hasIndex($tableName, [], $indexName); + } + /** * @inheritdoc */ @@ -1222,7 +1455,7 @@ public function addForeignKey(TableMetadata $table, ForeignKey $foreignKey): voi /** * Returns the instructions to adds the specified foreign key to a database table. * - * @param \Migrations\Db\Table\Table $table The table to add the constraint to + * @param \Migrations\Db\Table\TableMetadata $table The table to add the constraint to * @param \Migrations\Db\Table\ForeignKey $foreignKey The foreign key to add * @return \Migrations\Db\AlterInstructions */ @@ -1260,6 +1493,77 @@ abstract protected function getDropForeignKeyInstructions(string $tableName, str */ abstract protected function getDropForeignKeyByColumnsInstructions(string $tableName, array $columns): AlterInstructions; + /** + * @inheritDoc + */ + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool + { + $dialect = $this->getSchemaDialect(); + $columns = is_array($columns) ? $columns : [$columns]; + + return $dialect->hasForeignKey($tableName, $columns, $constraint); + } + + /** + * @inheritDoc + */ + public function hasCheckConstraint(string $tableName, string $constraintName): bool + { + $constraints = $this->getCheckConstraints($tableName); + + foreach ($constraints as $constraint) { + if ($constraint['name'] === $constraintName) { + return true; + } + } + + return false; + } + + /** + * Get check constraints for a table. + * + * @param string $tableName Table name + * @return array + */ + abstract protected function getCheckConstraints(string $tableName): array; + + /** + * @inheritDoc + */ + public function addCheckConstraint(TableMetadata $table, CheckConstraint $checkConstraint): void + { + $instructions = $this->getAddCheckConstraintInstructions($table, $checkConstraint); + $this->executeAlterSteps($table->getName(), $instructions); + } + + /** + * Returns the instructions to add the specified check constraint to a database table. + * + * @param \Migrations\Db\Table\TableMetadata $table The table to add the constraint to + * @param \Migrations\Db\Table\CheckConstraint $checkConstraint The check constraint + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getAddCheckConstraintInstructions(TableMetadata $table, CheckConstraint $checkConstraint): AlterInstructions; + + /** + * @inheritDoc + */ + public function dropCheckConstraint(string $tableName, string $constraintName): void + { + $instructions = $this->getDropCheckConstraintInstructions($tableName, $constraintName); + $this->executeAlterSteps($tableName, $instructions); + } + + /** + * Returns the instructions to drop the specified check constraint from a database table. + * + * @param string $tableName The table name + * @param string $constraintName The constraint name + * @return \Migrations\Db\AlterInstructions + */ + abstract protected function getDropCheckConstraintInstructions(string $tableName, string $constraintName): AlterInstructions; + /** * @inheritdoc */ @@ -1307,7 +1611,7 @@ public function changePrimaryKey(TableMetadata $table, string|array|null $newCol /** * Returns the instructions to change the primary key for the specified database table. * - * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\TableMetadata $table Table * @param string|string[]|null $newColumns Column name(s) to belong to the primary key, or null to drop the key * @return \Migrations\Db\AlterInstructions */ @@ -1325,7 +1629,7 @@ public function changeComment(TableMetadata $table, $newComment): void /** * Returns the instruction to change the comment for the specified database table. * - * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\TableMetadata $table Table * @param string|null $newComment New comment string, or null to drop the comment * @return \Migrations\Db\AlterInstructions */ @@ -1341,6 +1645,12 @@ public function executeActions(TableMetadata $table, array $actions): void { $instructions = new AlterInstructions(); + // Collect partition actions separately as they need special batching + /** @var \Migrations\Db\Table\PartitionDefinition[] $addPartitions */ + $addPartitions = []; + /** @var string[] $dropPartitions */ + $dropPartitions = []; + foreach ($actions as $action) { switch (true) { case $action instanceof AddColumn: @@ -1383,7 +1693,7 @@ public function executeActions(TableMetadata $table, array $actions): void )); break; - case $action instanceof DropIndex && $action->getIndex()->getName() !== null: + case $action instanceof DropIndex && $action->getIndex()->getName(): /** @var \Migrations\Db\Action\DropIndex $action */ $instructions->merge($this->getDropIndexByNameInstructions( $table->getName(), @@ -1391,7 +1701,7 @@ public function executeActions(TableMetadata $table, array $actions): void )); break; - case $action instanceof DropIndex && $action->getIndex()->getName() == null: + case $action instanceof DropIndex && !$action->getIndex()->getName(): /** @var \Migrations\Db\Action\DropIndex $action */ $instructions->merge($this->getDropIndexByColumnsInstructions( $table->getName(), @@ -1447,6 +1757,24 @@ public function executeActions(TableMetadata $table, array $actions): void )); break; + case $action instanceof AddPartition: + /** @var \Migrations\Db\Action\AddPartition $action */ + $addPartitions[] = $action->getPartition(); + break; + + case $action instanceof DropPartition: + /** @var \Migrations\Db\Action\DropPartition $action */ + $dropPartitions[] = $action->getPartitionName(); + break; + + case $action instanceof SetPartitioning: + /** @var \Migrations\Db\Action\SetPartitioning $action */ + $instructions->merge($this->getSetPartitioningInstructions( + $table, + $action->getPartition(), + )); + break; + default: throw new InvalidArgumentException( sprintf("Don't know how to execute action `%s`", get_class($action)), @@ -1454,6 +1782,57 @@ public function executeActions(TableMetadata $table, array $actions): void } } + // Handle batched partition operations + if ($addPartitions) { + $instructions->merge($this->getAddPartitionsInstructions($table, $addPartitions)); + } + if ($dropPartitions) { + $instructions->merge($this->getDropPartitionsInstructions($table->getName(), $dropPartitions)); + } + $this->executeAlterSteps($table->getName(), $instructions); } + + /** + * Get instructions for adding multiple partitions to an existing table. + * + * This method handles batching multiple partition additions into a single + * ALTER TABLE statement where supported by the database. + * + * @param \Migrations\Db\Table\TableMetadata $table The table + * @param array<\Migrations\Db\Table\PartitionDefinition> $partitions The partitions to add + * @return \Migrations\Db\AlterInstructions + */ + protected function getAddPartitionsInstructions(TableMetadata $table, array $partitions): AlterInstructions + { + throw new RuntimeException('Table partitioning is not supported by this adapter'); + } + + /** + * Get instructions for dropping multiple partitions from an existing table. + * + * This method handles batching multiple partition drops into a single + * ALTER TABLE statement where supported by the database. + * + * @param string $tableName The table name + * @param array $partitionNames The partition names to drop + * @return \Migrations\Db\AlterInstructions + */ + protected function getDropPartitionsInstructions(string $tableName, array $partitionNames): AlterInstructions + { + throw new RuntimeException('Table partitioning is not supported by this adapter'); + } + + /** + * Get instructions for adding partitioning to an existing table. + * + * @param \Migrations\Db\Table\TableMetadata $table The table + * @param \Migrations\Db\Table\Partition $partition The partition configuration + * @throws \RuntimeException If partitioning is not supported + * @return \Migrations\Db\AlterInstructions + */ + protected function getSetPartitioningInstructions(TableMetadata $table, Partition $partition): AlterInstructions + { + throw new RuntimeException('Adding partitioning to existing tables is not supported by this adapter'); + } } diff --git a/src/Db/Adapter/AdapterInterface.php b/src/Db/Adapter/AdapterInterface.php index 3219a3b93..74d43406a 100644 --- a/src/Db/Adapter/AdapterInterface.php +++ b/src/Db/Adapter/AdapterInterface.php @@ -16,99 +16,189 @@ use Cake\Database\Query\SelectQuery; use Cake\Database\Query\UpdateQuery; use Cake\Database\Schema\TableSchemaInterface; -use Migrations\Db\Literal; +use Migrations\Db\InsertMode; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; use Migrations\MigrationInterface; +use Migrations\SeedInterface; /** * Adapter Interface. */ interface AdapterInterface { - public const PHINX_TYPE_STRING = TableSchemaInterface::TYPE_STRING; - public const PHINX_TYPE_CHAR = TableSchemaInterface::TYPE_CHAR; - public const PHINX_TYPE_TEXT = TableSchemaInterface::TYPE_TEXT; - public const PHINX_TYPE_INTEGER = TableSchemaInterface::TYPE_INTEGER; - public const PHINX_TYPE_TINY_INTEGER = TableSchemaInterface::TYPE_TINYINTEGER; - public const PHINX_TYPE_SMALL_INTEGER = TableSchemaInterface::TYPE_SMALLINTEGER; - public const PHINX_TYPE_BIG_INTEGER = TableSchemaInterface::TYPE_BIGINTEGER; - - /** @deprecated Use smallinteger or boolean instead */ - public const PHINX_TYPE_BIT = 'bit'; - - public const PHINX_TYPE_FLOAT = TableSchemaInterface::TYPE_FLOAT; - public const PHINX_TYPE_DECIMAL = TableSchemaInterface::TYPE_DECIMAL; - - /** @deprecated Use float instead */ - public const PHINX_TYPE_DOUBLE = 'double'; - - public const PHINX_TYPE_DATETIME = TableSchemaInterface::TYPE_DATETIME; - public const PHINX_TYPE_TIMESTAMP = TableSchemaInterface::TYPE_TIMESTAMP; - public const PHINX_TYPE_TIME = TableSchemaInterface::TYPE_TIME; - public const PHINX_TYPE_DATE = TableSchemaInterface::TYPE_DATE; - public const PHINX_TYPE_BINARY = TableSchemaInterface::TYPE_BINARY; - - /** @deprecated Use binary instead */ - public const PHINX_TYPE_VARBINARY = 'varbinary'; - - public const PHINX_TYPE_BINARYUUID = TableSchemaInterface::TYPE_BINARY_UUID; - - /** @deprecated Use binary instead */ - public const PHINX_TYPE_BLOB = 'blob'; - - /** @deprecated Use binary with length instead */ - public const PHINX_TYPE_TINYBLOB = 'tinyblob'; // Specific to Mysql. - - /** @deprecated Use binary with length instead */ - public const PHINX_TYPE_MEDIUMBLOB = 'mediumblob'; // Specific to Mysql - - /** @deprecated Use binary with length instead */ - public const PHINX_TYPE_LONGBLOB = 'longblob'; // Specific to Mysql - public const PHINX_TYPE_BOOLEAN = TableSchemaInterface::TYPE_BOOLEAN; - public const PHINX_TYPE_JSON = TableSchemaInterface::TYPE_JSON; - public const PHINX_TYPE_UUID = TableSchemaInterface::TYPE_UUID; - public const PHINX_TYPE_NATIVEUUID = TableSchemaInterface::TYPE_NATIVE_UUID; - /** @deprecated Use json instead */ - public const PHINX_TYPE_JSONB = 'jsonb'; - /** @deprecated Use blob instead */ - public const PHINX_TYPE_FILESTREAM = 'filestream'; + public const TYPE_STRING = TableSchemaInterface::TYPE_STRING; + public const TYPE_CHAR = TableSchemaInterface::TYPE_CHAR; + public const TYPE_TEXT = TableSchemaInterface::TYPE_TEXT; + public const TYPE_INTEGER = TableSchemaInterface::TYPE_INTEGER; + public const TYPE_TINYINTEGER = TableSchemaInterface::TYPE_TINYINTEGER; + public const TYPE_SMALLINTEGER = TableSchemaInterface::TYPE_SMALLINTEGER; + public const TYPE_BIGINTEGER = TableSchemaInterface::TYPE_BIGINTEGER; + public const TYPE_FLOAT = TableSchemaInterface::TYPE_FLOAT; + public const TYPE_DECIMAL = TableSchemaInterface::TYPE_DECIMAL; + public const TYPE_DATETIME = TableSchemaInterface::TYPE_DATETIME; + public const TYPE_TIMESTAMP = TableSchemaInterface::TYPE_TIMESTAMP; + public const TYPE_TIME = TableSchemaInterface::TYPE_TIME; + public const TYPE_DATE = TableSchemaInterface::TYPE_DATE; + public const TYPE_BINARY = TableSchemaInterface::TYPE_BINARY; + public const TYPE_BINARY_UUID = TableSchemaInterface::TYPE_BINARY_UUID; + public const TYPE_BOOLEAN = TableSchemaInterface::TYPE_BOOLEAN; + public const TYPE_JSON = TableSchemaInterface::TYPE_JSON; + public const TYPE_UUID = TableSchemaInterface::TYPE_UUID; + public const TYPE_NATIVE_UUID = TableSchemaInterface::TYPE_NATIVE_UUID; // Geospatial database types - public const PHINX_TYPE_GEOMETRY = TableSchemaInterface::TYPE_GEOMETRY; - public const PHINX_TYPE_POINT = TableSchemaInterface::TYPE_POINT; - public const PHINX_TYPE_LINESTRING = TableSchemaInterface::TYPE_LINESTRING; - public const PHINX_TYPE_POLYGON = TableSchemaInterface::TYPE_POLYGON; - /** @deprecated Will be removed in 5.x */ - public const PHINX_TYPE_GEOGRAPHY = 'geography'; - - public const PHINX_TYPES_GEOSPATIAL = [ - self::PHINX_TYPE_GEOMETRY, - self::PHINX_TYPE_POINT, - self::PHINX_TYPE_LINESTRING, - self::PHINX_TYPE_POLYGON, + public const TYPE_GEOMETRY = TableSchemaInterface::TYPE_GEOMETRY; + public const TYPE_POINT = TableSchemaInterface::TYPE_POINT; + public const TYPE_LINESTRING = TableSchemaInterface::TYPE_LINESTRING; + public const TYPE_POLYGON = TableSchemaInterface::TYPE_POLYGON; + + public const TYPES_GEOSPATIAL = [ + self::TYPE_GEOMETRY, + self::TYPE_POINT, + self::TYPE_LINESTRING, + self::TYPE_POLYGON, ]; // only for mysql so far - /** @deprecated Will be removed in 5.x */ - public const PHINX_TYPE_MEDIUM_INTEGER = 'mediuminteger'; + public const TYPE_YEAR = TableSchemaInterface::TYPE_YEAR; - /** @deprecated Will be removed in 5.x */ - public const PHINX_TYPE_ENUM = 'enum'; + // only for postgresql so far + public const TYPE_CIDR = TableSchemaInterface::TYPE_CIDR; + public const TYPE_INET = TableSchemaInterface::TYPE_INET; + public const TYPE_MACADDR = TableSchemaInterface::TYPE_MACADDR; + public const TYPE_INTERVAL = TableSchemaInterface::TYPE_INTERVAL; - /** @deprecated Will be removed in 5.x */ - public const PHINX_TYPE_SET = 'set'; + /** + * @deprecated 5.0.0 Use TYPE_STRING instead. + */ + public const PHINX_TYPE_STRING = self::TYPE_STRING; + /** + * @deprecated 5.0.0 Use TYPE_CHAR instead. + */ + public const PHINX_TYPE_CHAR = self::TYPE_CHAR; + /** + * @deprecated 5.0.0 Use TYPE_TEXT instead. + */ + public const PHINX_TYPE_TEXT = self::TYPE_TEXT; + /** + * @deprecated 5.0.0 Use TYPE_INTEGER instead. + */ + public const PHINX_TYPE_INTEGER = self::TYPE_INTEGER; + /** + * @deprecated 5.0.0 Use TYPE_TINYINTEGER instead. + */ + public const PHINX_TYPE_TINY_INTEGER = self::TYPE_TINYINTEGER; + /** + * @deprecated 5.0.0 Use TYPE_SMALLINTEGER instead. + */ + public const PHINX_TYPE_SMALL_INTEGER = self::TYPE_SMALLINTEGER; + /** + * @deprecated 5.0.0 Use TYPE_BIGINTEGER instead. + */ + public const PHINX_TYPE_BIG_INTEGER = self::TYPE_BIGINTEGER; + /** + * @deprecated 5.0.0 Use TYPE_FLOAT instead. + */ + public const PHINX_TYPE_FLOAT = self::TYPE_FLOAT; + /** + * @deprecated 5.0.0 Use TYPE_DECIMAL instead. + */ + public const PHINX_TYPE_DECIMAL = self::TYPE_DECIMAL; + /** + * @deprecated 5.0.0 Use TYPE_DATETIME instead. + */ + public const PHINX_TYPE_DATETIME = self::TYPE_DATETIME; + /** + * @deprecated 5.0.0 Use TYPE_TIMESTAMP instead. + */ + public const PHINX_TYPE_TIMESTAMP = self::TYPE_TIMESTAMP; + /** + * @deprecated 5.0.0 Use TYPE_TIME instead. + */ + public const PHINX_TYPE_TIME = self::TYPE_TIME; + /** + * @deprecated 5.0.0 Use TYPE_DATE instead. + */ + public const PHINX_TYPE_DATE = self::TYPE_DATE; + /** + * @deprecated 5.0.0 Use TYPE_BINARY instead. + */ + public const PHINX_TYPE_BINARY = self::TYPE_BINARY; + /** + * @deprecated 5.0.0 Use TYPE_BINARY_UUID instead. + */ + public const PHINX_TYPE_BINARYUUID = self::TYPE_BINARY_UUID; + /** + * @deprecated 5.0.0 Use TYPE_BOOLEAN instead. + */ + public const PHINX_TYPE_BOOLEAN = self::TYPE_BOOLEAN; + /** + * @deprecated 5.0.0 Use TYPE_JSON instead. + */ + public const PHINX_TYPE_JSON = self::TYPE_JSON; + /** + * @deprecated 5.0.0 Use TableSchemaInterface::TYPE_JSON instead. + */ + public const PHINX_TYPE_JSONB = 'jsonb'; + /** + * @deprecated 5.0.0 Use TYPE_UUID instead. + */ + public const PHINX_TYPE_UUID = self::TYPE_UUID; + /** + * @deprecated 5.0.0 Use TYPE_NATIVE_UUID instead. + */ + public const PHINX_TYPE_NATIVEUUID = self::TYPE_NATIVE_UUID; - // only for mysql so far - // TODO This can be aliased to TableSchema constants with cakephp 5.3 - public const PHINX_TYPE_YEAR = 'year'; + /** + * @deprecated 5.0.0 Use TYPE_GEOMETRY instead. + */ + public const PHINX_TYPE_GEOMETRY = self::TYPE_GEOMETRY; + /** + * @deprecated 5.0.0 Use TYPE_POINT instead. + */ + public const PHINX_TYPE_POINT = self::TYPE_POINT; + /** + * @deprecated 5.0.0 Use TYPE_LINESTRING instead. + */ + public const PHINX_TYPE_LINESTRING = self::TYPE_LINESTRING; + /** + * @deprecated 5.0.0 Use TYPE_POLYGON instead. + */ + public const PHINX_TYPE_POLYGON = self::TYPE_POLYGON; - // only for postgresql so far - // TODO These can be aliased to TableSchema constants with cakephp 5.3 - public const PHINX_TYPE_CIDR = 'cidr'; - public const PHINX_TYPE_INET = 'inet'; - public const PHINX_TYPE_MACADDR = 'macaddr'; - public const PHINX_TYPE_INTERVAL = 'interval'; + /** + * @deprecated 5.0.0 Use TYPES_GEOSPATIAL instead. + */ + public const PHINX_TYPES_GEOSPATIAL = [ + self::TYPE_GEOMETRY, + self::TYPE_POINT, + self::TYPE_LINESTRING, + self::TYPE_POLYGON, + ]; + + /** + * @deprecated 5.0.0 Use TYPE_YEAR instead. + */ + public const PHINX_TYPE_YEAR = self::TYPE_YEAR; + + /** + * @deprecated 5.0.0 Use TYPE_CIDR instead. + */ + public const PHINX_TYPE_CIDR = self::TYPE_CIDR; + /** + * @deprecated 5.0.0 Use TYPE_INET instead. + */ + public const PHINX_TYPE_INET = self::TYPE_INET; + /** + * @deprecated 5.0.0 Use TYPE_MACADDR instead. + */ + public const PHINX_TYPE_MACADDR = self::TYPE_MACADDR; + /** + * @deprecated 5.0.0 Use TYPE_INTERVAL instead. + */ + public const PHINX_TYPE_INTERVAL = self::TYPE_INTERVAL; /** * Get all migrated version numbers. @@ -215,6 +305,44 @@ public function unsetBreakpoint(MigrationInterface $migration); */ public function createSchemaTable(): void; + /** + * Creates the seed schema table. + * + * @return void + */ + public function createSeedSchemaTable(): void; + + /** + * Gets the seed schema table name. + * + * @return string + */ + public function getSeedSchemaTableName(): string; + + /** + * Get all seed log entries. + * + * @return array + */ + public function getSeedLog(): array; + + /** + * Records a seed being executed. + * + * @param \Migrations\SeedInterface $seed Seed + * @param string $executedTime Executed Time + * @return $this + */ + public function seedExecuted(SeedInterface $seed, string $executedTime); + + /** + * Removes a seed from the log. + * + * @param \Migrations\SeedInterface $seed Seed + * @return $this + */ + public function removeSeedFromLog(SeedInterface $seed); + /** * Returns the adapter type. * @@ -277,11 +405,11 @@ public function execute(string $sql, array $params = []): int; /** * Executes a list of migration actions for the given table * - * @param \Migrations\Db\Table\Table $table The table to execute the actions for + * @param \Migrations\Db\Table\TableMetadata $table The table to execute the actions for * @param \Migrations\Db\Action\Action[] $actions The table to execute the actions for * @return void */ - public function executeActions(Table $table, array $actions): void; + public function executeActions(TableMetadata $table, array $actions): void; /** * Returns a new Query object @@ -349,20 +477,38 @@ public function fetchAll(string $sql): array; /** * Inserts data into a table. * - * @param \Migrations\Db\Table\Table $table Table where to insert data + * @param \Migrations\Db\Table\TableMetadata $table Table where to insert data * @param array $row Row + * @param \Migrations\Db\InsertMode|null $mode Insert mode + * @param array|null $updateColumns Columns to update on upsert conflict + * @param array|null $conflictColumns Columns that define uniqueness for upsert * @return void */ - public function insert(Table $table, array $row): void; + public function insert( + TableMetadata $table, + array $row, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): void; /** * Inserts data into a table in a bulk. * - * @param \Migrations\Db\Table\Table $table Table where to insert data + * @param \Migrations\Db\Table\TableMetadata $table Table where to insert data * @param array $rows Rows + * @param \Migrations\Db\InsertMode|null $mode Insert mode + * @param array|null $updateColumns Columns to update on upsert conflict + * @param array|null $conflictColumns Columns that define uniqueness for upsert * @return void */ - public function bulkinsert(Table $table, array $rows): void; + public function bulkinsert( + TableMetadata $table, + array $rows, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): void; /** * Quotes a table name for use in a query. @@ -391,12 +537,12 @@ public function hasTable(string $tableName): bool; /** * Creates the specified database table. * - * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\TableMetadata $table Table * @param \Migrations\Db\Table\Column[] $columns List of columns in the table * @param \Migrations\Db\Table\Index[] $indexes List of indexes for the table * @return void */ - public function createTable(Table $table, array $columns = [], array $indexes = []): void; + public function createTable(TableMetadata $table, array $columns = [], array $indexes = []): void; /** * Truncates the specified table @@ -462,7 +608,34 @@ public function hasPrimaryKey(string $tableName, string|array $columns, ?string public function hasForeignKey(string $tableName, string|array $columns, ?string $constraint = null): bool; /** - * Returns an array of the supported Phinx column types. + * Checks to see if a check constraint exists. + * + * @param string $tableName Table name + * @param string $constraintName Constraint name + * @return bool + */ + public function hasCheckConstraint(string $tableName, string $constraintName): bool; + + /** + * Adds a check constraint to a database table. + * + * @param \Migrations\Db\Table\TableMetadata $table Table + * @param \Migrations\Db\Table\CheckConstraint $checkConstraint Check constraint + * @return void + */ + public function addCheckConstraint(TableMetadata $table, CheckConstraint $checkConstraint): void; + + /** + * Drops a check constraint from a database table. + * + * @param string $tableName Table name + * @param string $constraintName Constraint name + * @return void + */ + public function dropCheckConstraint(string $tableName, string $constraintName): void; + + /** + * Returns an array of the supported column types. * * @return string[] */ @@ -476,15 +649,6 @@ public function getColumnTypes(): array; */ public function isValidColumnType(Column $column): bool; - /** - * Converts the Phinx logical type to the adapter's SQL type. - * - * @param \Migrations\Db\Literal|string $type Type - * @param int|null $limit Limit - * @return array - */ - public function getSqlType(Literal|string $type, ?int $limit = null): array; - /** * Creates a new database. * @@ -502,6 +666,16 @@ public function createDatabase(string $name, array $options = []): void; */ public function hasDatabase(string $name): bool; + /** + * Cleanup missing migrations from the migration tracking table. + * + * Removes entries from the migrations table for migrations that no longer exist + * in the migrations directory (marked as MISSING in status output). + * + * @return void + */ + public function cleanupMissing(array $missingVersions): void; + /** * Drops the specified database. * @@ -557,4 +731,15 @@ public function getIo(): ?ConsoleIo; * @return \Cake\Database\Connection The connection */ public function getConnection(): Connection; + + /** + * Gets the schema table name. + * + * Returns the table name used for migration tracking based on configuration: + * - 'cake_migrations' for unified mode + * - 'phinxlog' or '{plugin}_phinxlog' for legacy mode + * + * @return string The migration tracking table name + */ + public function getSchemaTableName(): string; } diff --git a/src/Db/Adapter/AdapterWrapper.php b/src/Db/Adapter/AdapterWrapper.php index 7fe5ba637..597e9926d 100644 --- a/src/Db/Adapter/AdapterWrapper.php +++ b/src/Db/Adapter/AdapterWrapper.php @@ -15,10 +15,12 @@ use Cake\Database\Query\InsertQuery; use Cake\Database\Query\SelectQuery; use Cake\Database\Query\UpdateQuery; -use Migrations\Db\Literal; +use Migrations\Db\InsertMode; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; use Migrations\MigrationInterface; +use Migrations\SeedInterface; /** * Adapter Wrapper. @@ -136,17 +138,27 @@ public function query(string $sql, array $params = []): mixed /** * @inheritDoc */ - public function insert(Table $table, array $row): void - { - $this->getAdapter()->insert($table, $row); + public function insert( + TableMetadata $table, + array $row, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): void { + $this->getAdapter()->insert($table, $row, $mode, $updateColumns, $conflictColumns); } /** * @inheritDoc */ - public function bulkinsert(Table $table, array $rows): void - { - $this->getAdapter()->bulkinsert($table, $rows); + public function bulkinsert( + TableMetadata $table, + array $rows, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): void { + $this->getAdapter()->bulkinsert($table, $rows, $mode, $updateColumns, $conflictColumns); } /** @@ -181,6 +193,14 @@ public function getVersionLog(): array return $this->getAdapter()->getVersionLog(); } + /** + * @inheritDoc + */ + public function cleanupMissing(array $missingVersions): void + { + $this->getAdapter()->cleanupMissing($missingVersions); + } + /** * @inheritDoc */ @@ -237,6 +257,50 @@ public function createSchemaTable(): void $this->getAdapter()->createSchemaTable(); } + /** + * @inheritDoc + */ + public function createSeedSchemaTable(): void + { + $this->getAdapter()->createSeedSchemaTable(); + } + + /** + * @inheritDoc + */ + public function getSeedSchemaTableName(): string + { + return $this->getAdapter()->getSeedSchemaTableName(); + } + + /** + * @inheritDoc + */ + public function getSeedLog(): array + { + return $this->getAdapter()->getSeedLog(); + } + + /** + * @inheritDoc + */ + public function seedExecuted(SeedInterface $seed, string $executedTime): AdapterInterface + { + $this->getAdapter()->seedExecuted($seed, $executedTime); + + return $this; + } + + /** + * @inheritDoc + */ + public function removeSeedFromLog(SeedInterface $seed): AdapterInterface + { + $this->getAdapter()->removeSeedFromLog($seed); + + return $this; + } + /** * @inheritDoc */ @@ -312,7 +376,7 @@ public function hasTable(string $tableName): bool /** * @inheritDoc */ - public function createTable(Table $table, array $columns = [], array $indexes = []): void + public function createTable(TableMetadata $table, array $columns = [], array $indexes = []): void { $this->getAdapter()->createTable($table, $columns, $indexes); } @@ -365,14 +429,6 @@ public function hasForeignKey(string $tableName, $columns, ?string $constraint = return $this->getAdapter()->hasForeignKey($tableName, $columns, $constraint); } - /** - * @inheritDoc - */ - public function getSqlType(Literal|string $type, ?int $limit = null): array - { - return $this->getAdapter()->getSqlType($type, $limit); - } - /** * @inheritDoc */ @@ -440,7 +496,7 @@ public function getConnection(): Connection /** * @inheritDoc */ - public function executeActions(Table $table, array $actions): void + public function executeActions(TableMetadata $table, array $actions): void { $this->getAdapter()->executeActions($table, $actions); } @@ -485,6 +541,30 @@ public function getDeleteBuilder(): DeleteQuery return $this->getAdapter()->getDeleteBuilder(); } + /** + * @inheritDoc + */ + public function hasCheckConstraint(string $tableName, string $constraintName): bool + { + return $this->getAdapter()->hasCheckConstraint($tableName, $constraintName); + } + + /** + * @inheritDoc + */ + public function addCheckConstraint(TableMetadata $table, CheckConstraint $checkConstraint): void + { + $this->getAdapter()->addCheckConstraint($table, $checkConstraint); + } + + /** + * @inheritDoc + */ + public function dropCheckConstraint(string $tableName, string $constraintName): void + { + $this->getAdapter()->dropCheckConstraint($tableName, $constraintName); + } + /** * @inheritDoc */ @@ -502,4 +582,12 @@ public function getIo(): ?ConsoleIo { return $this->getAdapter()->getIo(); } + + /** + * @inheritDoc + */ + public function getSchemaTableName(): string + { + return $this->getAdapter()->getSchemaTableName(); + } } diff --git a/src/Db/Adapter/DirectActionInterface.php b/src/Db/Adapter/DirectActionInterface.php index 67141c3f1..3dd6833c1 100644 --- a/src/Db/Adapter/DirectActionInterface.php +++ b/src/Db/Adapter/DirectActionInterface.php @@ -11,7 +11,7 @@ use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; /** * Represents an adapter that is capable of directly executing alter @@ -39,29 +39,29 @@ public function dropTable(string $tableName): void; /** * Changes the primary key of the specified database table. * - * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\TableMetadata $table Table * @param string|string[]|null $newColumns Column name(s) to belong to the primary key, or null to drop the key * @return void */ - public function changePrimaryKey(Table $table, string|array|null $newColumns): void; + public function changePrimaryKey(TableMetadata $table, string|array|null $newColumns): void; /** * Changes the comment of the specified database table. * - * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\TableMetadata $table Table * @param string|null $newComment New comment string, or null to drop the comment * @return void */ - public function changeComment(Table $table, ?string $newComment): void; + public function changeComment(TableMetadata $table, ?string $newComment): void; /** * Adds the specified column to a database table. * - * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\TableMetadata $table Table * @param \Migrations\Db\Table\Column $column Column * @return void */ - public function addColumn(Table $table, Column $column): void; + public function addColumn(TableMetadata $table, Column $column): void; /** * Renames the specified column. @@ -95,11 +95,11 @@ public function dropColumn(string $tableName, string $columnName): void; /** * Adds the specified index to a database table. * - * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\TableMetadata $table Table * @param \Migrations\Db\Table\Index $index Index * @return void */ - public function addIndex(Table $table, Index $index): void; + public function addIndex(TableMetadata $table, Index $index): void; /** * Drops the specified index from a database table. @@ -122,11 +122,11 @@ public function dropIndexByName(string $tableName, string $indexName): void; /** * Adds the specified foreign key to a database table. * - * @param \Migrations\Db\Table\Table $table The table to add the foreign key to + * @param \Migrations\Db\Table\TableMetadata $table The table to add the foreign key to * @param \Migrations\Db\Table\ForeignKey $foreignKey The foreign key to add * @return void */ - public function addForeignKey(Table $table, ForeignKey $foreignKey): void; + public function addForeignKey(TableMetadata $table, ForeignKey $foreignKey): void; /** * Drops the specified foreign key from a database table. diff --git a/src/Db/Adapter/MigrationsTableStorage.php b/src/Db/Adapter/MigrationsTableStorage.php new file mode 100644 index 000000000..a859d2621 --- /dev/null +++ b/src/Db/Adapter/MigrationsTableStorage.php @@ -0,0 +1,242 @@ + $orderBy The order by clause. + * @return \Cake\Database\Query\SelectQuery + */ + public function getVersions(array $orderBy): SelectQuery + { + $query = $this->adapter->getSelectBuilder(); + $query->select('*') + ->from($this->schemaTableName) + ->orderBy($orderBy); + + return $query; + } + + /** + * Cleanup missing migrations from the migration tracking table. + * + * Removes entries from the migrations table for migrations that no longer exist + * in the migrations directory (marked as MISSING in status output). + * + * @param array $missingVersions The list of missing migration versions. + * @return void + */ + public function cleanupMissing(array $missingVersions): void + { + $this->adapter->beginTransaction(); + try { + $where = ['version IN' => $missingVersions]; + $delete = $this->adapter->getDeleteBuilder() + ->from($this->schemaTableName) + ->where($where); + $delete->execute(); + $this->adapter->commitTransaction(); + } catch (Exception $e) { + $this->adapter->rollbackTransaction(); + throw $e; + } + } + + /** + * Records that a migration was run in the database. + * + * @param \Migrations\MigrationInterface $migration Migration + * @param string $startTime Start time + * @param string $endTime End time + * @return void + */ + public function recordUp(MigrationInterface $migration, string $startTime, string $endTime): void + { + $query = $this->adapter->getInsertBuilder(); + $query->insert(['version', 'migration_name', 'start_time', 'end_time', 'breakpoint']) + ->into($this->schemaTableName) + ->values([ + 'version' => (string)$migration->getVersion(), + 'migration_name' => substr($migration->getName(), 0, 100), + 'start_time' => $startTime, + 'end_time' => $endTime, + 'breakpoint' => 0, + ]); + $this->adapter->executeQuery($query); + } + + /** + * Removes the record of a migration having been run. + * + * @param \Migrations\MigrationInterface $migration Migration + * @return void + */ + public function recordDown(MigrationInterface $migration): void + { + $query = $this->adapter->getDeleteBuilder(); + $query->delete() + ->from($this->schemaTableName) + ->where(['version' => (string)$migration->getVersion()]); + $this->adapter->executeQuery($query); + } + + /** + * Toggles the breakpoint state of a migration. + * + * @param \Migrations\MigrationInterface $migration Migration + * @return void + */ + public function toggleBreakpoint(MigrationInterface $migration): void + { + $params = [ + $migration->getVersion(), + ]; + $this->adapter->query( + sprintf( + 'UPDATE %1$s SET %2$s = CASE %2$s WHEN true THEN false ELSE true END, %4$s = %4$s WHERE %3$s = ?;', + $this->adapter->quoteTableName($this->schemaTableName), + $this->adapter->quoteColumnName('breakpoint'), + $this->adapter->quoteColumnName('version'), + $this->adapter->quoteColumnName('start_time'), + ), + $params, + ); + } + + /** + * Resets all breakpoints. + * + * @return int The number of affected rows. + */ + public function resetAllBreakpoints(): int + { + $query = $this->adapter->getUpdateBuilder(); + $query->update($this->schemaTableName) + ->set([ + 'breakpoint' => 0, + 'start_time' => $query->identifier('start_time'), + ]) + ->where([ + 'breakpoint !=' => 0, + ]); + + return $this->adapter->executeQuery($query); + } + + /** + * Marks a migration as a breakpoint or not depending on $state. + * + * @param \Migrations\MigrationInterface $migration Migration + * @param bool $state The breakpoint state to set. + * @return void + */ + public function markBreakpoint(MigrationInterface $migration, bool $state): void + { + $query = $this->adapter->getUpdateBuilder(); + $query->update($this->schemaTableName) + ->set([ + 'breakpoint' => (int)$state, + 'start_time' => $query->identifier('start_time'), + ]) + ->where([ + 'version' => $migration->getVersion(), + ]); + $this->adapter->executeQuery($query); + } + + /** + * Creates the migration storage table + * + * @return void + * @throws \InvalidArgumentException When there is a problem creating the table. + */ + public function createTable(): void + { + try { + $options = [ + 'id' => false, + 'primary_key' => 'version', + ]; + + $table = new Table($this->schemaTableName, $options, $this->adapter); + $table->addColumn('version', 'biginteger', ['null' => false]) + ->addColumn('migration_name', 'string', ['limit' => 100, 'default' => null, 'null' => true]) + ->addColumn('start_time', 'timestamp', ['default' => null, 'null' => true]) + ->addColumn('end_time', 'timestamp', ['default' => null, 'null' => true]) + ->addColumn('breakpoint', 'boolean', ['default' => false, 'null' => false]) + ->save(); + } catch (Exception $exception) { + throw new InvalidArgumentException( + 'There was a problem creating the schema table: ' . $exception->getMessage(), + (int)$exception->getCode(), + $exception, + ); + } + } + + /** + * Upgrades the migration storage table + * + * @return void + */ + public function upgradeTable(): void + { + $table = new Table($this->schemaTableName, [], $this->adapter); + if (!$table->hasColumn('migration_name')) { + $table + ->addColumn( + 'migration_name', + 'string', + ['limit' => 100, 'after' => 'version', 'default' => null, 'null' => true], + ) + ->save(); + } + if (!$table->hasColumn('breakpoint')) { + $table + ->addColumn('breakpoint', 'boolean', ['default' => false, 'null' => false]) + ->save(); + } + } +} diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index bdbdafebb..0873581a4 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -16,13 +16,16 @@ use InvalidArgumentException; use Migrations\Db\AlterInstructions; use Migrations\Db\Literal; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\Partition; +use Migrations\Db\Table\PartitionDefinition; +use Migrations\Db\Table\TableMetadata; /** - * Phinx MySQL Adapter. + * MySQL Adapter. */ class MysqlAdapter extends AbstractAdapter { @@ -30,37 +33,51 @@ class MysqlAdapter extends AbstractAdapter * @var string[] */ protected static array $specificColumnTypes = [ + self::TYPE_YEAR, + self::TYPE_JSON, + self::TYPE_BINARY_UUID, self::PHINX_TYPE_ENUM, self::PHINX_TYPE_SET, - self::PHINX_TYPE_YEAR, - self::PHINX_TYPE_JSON, - self::PHINX_TYPE_BINARYUUID, + self::PHINX_TYPE_BLOB, self::PHINX_TYPE_TINYBLOB, self::PHINX_TYPE_MEDIUMBLOB, self::PHINX_TYPE_LONGBLOB, - self::PHINX_TYPE_MEDIUM_INTEGER, ]; /** - * @var bool[] + * @deprecated 5.0.0 Enum column support will be removed in a future release. */ - protected array $signedColumnTypes = [ - self::PHINX_TYPE_INTEGER => true, - self::PHINX_TYPE_TINY_INTEGER => true, - self::PHINX_TYPE_SMALL_INTEGER => true, - self::PHINX_TYPE_MEDIUM_INTEGER => true, - self::PHINX_TYPE_BIG_INTEGER => true, - self::PHINX_TYPE_FLOAT => true, - self::PHINX_TYPE_DECIMAL => true, - self::PHINX_TYPE_DOUBLE => true, - self::PHINX_TYPE_BOOLEAN => true, - ]; + public const PHINX_TYPE_ENUM = 'enum'; + /** + * @deprecated 5.0.0 Set column support will be removed in a future release. + */ + public const PHINX_TYPE_SET = 'set'; + /** + * @deprecated 5.0.0 Use binary type with with no limit instead. + */ + public const PHINX_TYPE_BLOB = 'blob'; + /** + * @deprecated 5.0.0 Use binary type with with limit BLOB_SMALL instead. + */ + public const PHINX_TYPE_TINYBLOB = 'tinyblob'; + /** + * @deprecated 5.0.0 Use binary type with with limit BLOB_MEDIUM instead. + */ + public const PHINX_TYPE_MEDIUMBLOB = 'mediumblob'; + /** + * @deprecated 5.0.0 Use binary type with with limit BLOB_LONG instead. + */ + public const PHINX_TYPE_LONGBLOB = 'longblob'; + /** + * @deprecated 5.0.0 Use binary type instead. + */ + public const PHINX_TYPE_VARBINARY = 'varbinary'; // These constants roughly correspond to the maximum allowed value for each field, // except for the `_LONG` and `_BIG` variants, which are maxed at 32-bit // PHP_INT_MAX value. The `INT_REGULAR` field is just arbitrarily half of INT_BIG // as its actual value is its regular value is larger than PHP_INT_MAX. We do this - // to keep consistent the type hints for getSqlType and Column::$limit being integers. + // to keep consistent the type hints for Column::$limit being integers. public const TEXT_TINY = 255; public const TEXT_SMALL = 255; /* deprecated, alias of TEXT_TINY */ /** @deprecated Use length of null instead **/ @@ -93,6 +110,77 @@ class MysqlAdapter extends AbstractAdapter public const FIRST = 'FIRST'; + /** + * MySQL ALTER TABLE ALGORITHM options + * + * These constants control how MySQL performs ALTER TABLE operations: + * - ALGORITHM_DEFAULT: Let MySQL choose the best algorithm + * - ALGORITHM_INSTANT: Instant operation (no table copy, MySQL 8.0+ / MariaDB 10.3+) + * - ALGORITHM_INPLACE: In-place operation (no full table copy) + * - ALGORITHM_COPY: Traditional table copy algorithm + * + * Usage: + * ```php + * use Migrations\Db\Adapter\MysqlAdapter; + * + * // ALGORITHM=INSTANT alone (recommended) + * $table->addColumn('status', 'string', [ + * 'null' => true, + * 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + * ]); + * + * // Or with ALGORITHM=INPLACE and explicit LOCK + * $table->addColumn('status', 'string', [ + * 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + * 'lock' => MysqlAdapter::LOCK_NONE, + * ]); + * ``` + * + * Important: ALGORITHM=INSTANT cannot be combined with LOCK=NONE, LOCK=SHARED, + * or LOCK=EXCLUSIVE (MySQL restriction). Use ALGORITHM=INSTANT alone or with + * LOCK=DEFAULT only. + * + * Note: ALGORITHM_INSTANT requires MySQL 8.0+ or MariaDB 10.3+ and only works for + * compatible operations (adding nullable columns, dropping columns, etc.). + * If the operation cannot be performed instantly, MySQL will return an error. + * + * @see https://dev.mysql.com/doc/refman/8.0/en/alter-table.html + * @see https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html + * @see https://mariadb.com/kb/en/alter-table/#algorithm + */ + public const ALGORITHM_DEFAULT = 'DEFAULT'; + public const ALGORITHM_INSTANT = 'INSTANT'; + public const ALGORITHM_INPLACE = 'INPLACE'; + public const ALGORITHM_COPY = 'COPY'; + + /** + * MySQL ALTER TABLE LOCK options + * + * These constants control the locking behavior during ALTER TABLE operations: + * - LOCK_DEFAULT: Let MySQL choose the appropriate lock level + * - LOCK_NONE: Allow concurrent reads and writes (least restrictive) + * - LOCK_SHARED: Allow concurrent reads, block writes + * - LOCK_EXCLUSIVE: Block all concurrent access (most restrictive) + * + * Usage: + * ```php + * use Migrations\Db\Adapter\MysqlAdapter; + * + * $table->changeColumn('name', 'string', [ + * 'limit' => 500, + * 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + * 'lock' => MysqlAdapter::LOCK_NONE, + * ]); + * ``` + * + * @see https://dev.mysql.com/doc/refman/8.0/en/alter-table.html + * @see https://mariadb.com/kb/en/alter-table/#lock + */ + public const LOCK_DEFAULT = 'DEFAULT'; + public const LOCK_NONE = 'NONE'; + public const LOCK_SHARED = 'SHARED'; + public const LOCK_EXCLUSIVE = 'EXCLUSIVE'; + /** * @inheritDoc */ @@ -118,7 +206,10 @@ public function quoteTableName(string $tableName): string */ public function hasTable(string $tableName): bool { - if ($this->hasCreatedTable($tableName)) { + // Only use the cache in dry-run mode where tables aren't actually created. + // In normal mode, always check the database to handle cases where tables + // are dropped via execute() which doesn't update the cache. + if ($this->isDryRunEnabled() && $this->hasCreatedTable($tableName)) { return true; } @@ -144,25 +235,18 @@ public function hasTable(string $tableName): bool protected function hasTableWithSchema(string $schema, string $tableName): bool { $dialect = $this->getSchemaDialect(); - [$query, $params] = $dialect->listTablesSql(['database' => $schema]); try { - $statement = $this->query($query, $params); - } catch (QueryException $e) { + return $dialect->hasTable($tableName, $schema); + } catch (QueryException) { return false; } - $tables = []; - foreach ($statement->fetchAll() as $row) { - $tables[] = $row[0]; - } - - return in_array($tableName, $tables, true); } /** * @inheritDoc */ - public function createTable(Table $table, array $columns = [], array $indexes = []): void + public function createTable(TableMetadata $table, array $columns = [], array $indexes = []): void { // This method is based on the MySQL docs here: https://dev.mysql.com/doc/refman/5.1/en/create-index.html $defaultOptions = [ @@ -259,6 +343,12 @@ public function createTable(Table $table, array $columns = [], array $indexes = $sql .= ') ' . $optionsStr; $sql = rtrim($sql); + // add partitioning + $partition = $table->getPartition(); + if ($partition !== null) { + $sql .= ' ' . $this->getPartitionSqlDefinition($partition); + } + // execute the sql $this->execute($sql); @@ -269,12 +359,12 @@ public function createTable(Table $table, array $columns = [], array $indexes = * Apply MySQL specific translations between the values using migrations constants/types * and the cakephp/database constants. Over time, these can be aligned. * - * @param array $data The raw column data. - * @return array Modified column data. + * @param array $data The raw column data. + * @return array Modified column data. */ protected function mapColumnData(array $data): array { - if ($data['type'] == self::PHINX_TYPE_TEXT && $data['length'] !== null) { + if ($data['type'] == self::TYPE_TEXT && $data['length'] !== null) { $data['length'] = match ($data['length']) { self::TEXT_LONG => TableSchema::LENGTH_LONG, self::TEXT_MEDIUM => TableSchema::LENGTH_MEDIUM, @@ -283,23 +373,15 @@ protected function mapColumnData(array $data): array default => null, }; } - $binaryTypes = [ + $blobTypes = [ + self::TYPE_BINARY, + self::PHINX_TYPE_VARBINARY, self::PHINX_TYPE_BLOB, self::PHINX_TYPE_TINYBLOB, self::PHINX_TYPE_MEDIUMBLOB, self::PHINX_TYPE_LONGBLOB, - self::PHINX_TYPE_VARBINARY, - self::PHINX_TYPE_BINARY, ]; - if (in_array($data['type'], $binaryTypes, true)) { - if (!isset($data['length'])) { - $data['length'] = match ($data['type']) { - self::PHINX_TYPE_TINYBLOB => TableSchema::LENGTH_TINY, - self::PHINX_TYPE_MEDIUMBLOB => TableSchema::LENGTH_MEDIUM, - self::PHINX_TYPE_LONGBLOB => TableSchema::LENGTH_LONG, - default => $data['length'], - }; - } + if (in_array($data['type'], $blobTypes, true)) { if ($data['length'] === self::BLOB_REGULAR) { $data['type'] = TableSchema::TYPE_BINARY; $data['length'] = null; @@ -318,16 +400,21 @@ protected function mapColumnData(array $data): array break; } } + if ($data['length'] === null) { + $data['length'] = match ($data['type']) { + self::PHINX_TYPE_TINYBLOB => TableSchema::LENGTH_TINY, + self::PHINX_TYPE_MEDIUMBLOB => TableSchema::LENGTH_MEDIUM, + self::PHINX_TYPE_LONGBLOB => TableSchema::LENGTH_LONG, + default => null, + }; + } $data['type'] = 'binary'; - } elseif ($data['type'] === self::PHINX_TYPE_INTEGER) { + } elseif ($data['type'] === self::TYPE_INTEGER) { if (isset($data['length']) && $data['length'] === self::INT_BIG) { $data['type'] = TableSchema::TYPE_BIGINTEGER; unset($data['length']); } unset($data['length']); - } elseif ($data['type'] == self::PHINX_TYPE_DOUBLE) { - $data['type'] = TableSchema::TYPE_FLOAT; - $data['length'] = 52; } return $data; @@ -376,7 +463,7 @@ protected function columnDefinitionSql(SchemaDialect $dialect, Column $column): * * @throws \InvalidArgumentException */ - protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): AlterInstructions + protected function getChangePrimaryKeyInstructions(TableMetadata $table, $newColumns): AlterInstructions { $instructions = new AlterInstructions(); @@ -409,7 +496,7 @@ protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): A /** * @inheritDoc */ - protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions + protected function getChangeCommentInstructions(TableMetadata $table, ?string $newComment): AlterInstructions { $instructions = new AlterInstructions(); @@ -481,6 +568,7 @@ protected function mapColumnType(array $columnData): array $type = 'timestamp'; $length = $columnData['precision'] ?? $length; } elseif ($type === TableSchema::TYPE_BINARY) { + // TODO could rawType be removed? We should be able to use the abstract type and length only. // CakePHP returns BLOB columns as 'binary' with specific lengths // Check the raw MySQL type to distinguish BLOB from BINARY columns $rawType = $columnData['rawType'] ?? ''; @@ -537,9 +625,8 @@ public function getColumns(string $tableName): array ->setScale($record['precision'] ?? null) ->setComment($record['comment']); - if ($record['unsigned'] ?? false) { - $column->setSigned(!$record['unsigned']); - } + // Always set unsigned property based on unsigned flag + $column->setUnsigned($record['unsigned'] ?? false); if ($record['autoIncrement'] ?? false) { $column->setIdentity(true); } @@ -558,20 +645,15 @@ public function getColumns(string $tableName): array */ public function hasColumn(string $tableName, string $columnName): bool { - $rows = $this->fetchAll(sprintf('SHOW COLUMNS FROM %s', $this->quoteTableName($tableName))); - foreach ($rows as $column) { - if (strcasecmp($column['Field'], $columnName) === 0) { - return true; - } - } + $dialect = $this->getSchemaDialect(); - return false; + return $dialect->hasColumn($tableName, $columnName); } /** * @inheritDoc */ - protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + protected function getAddColumnInstructions(TableMetadata $table, Column $column): AlterInstructions { $dialect = $this->getSchemaDialect(); $alter = sprintf( @@ -581,7 +663,16 @@ protected function getAddColumnInstructions(Table $table, Column $column): Alter $alter .= $this->afterClause($column); - return new AlterInstructions([$alter]); + $instructions = new AlterInstructions([$alter]); + + if ($column->getAlgorithm() !== null) { + $instructions->setAlgorithm($column->getAlgorithm()); + } + if ($column->getLock() !== null) { + $instructions->setLock($column->getLock()); + } + + return $instructions; } /** @@ -611,6 +702,24 @@ protected function afterClause(Column $column): string */ protected function getRenameColumnInstructions(string $tableName, string $columnName, string $newColumnName): AlterInstructions { + $columns = $this->getColumns($tableName); + $targetColumn = null; + + foreach ($columns as $column) { + if (strcasecmp($column->getName(), $columnName) === 0) { + $targetColumn = $column; + break; + } + } + + if ($targetColumn === null) { + throw new InvalidArgumentException(sprintf( + "The specified column doesn't exist: %s", + $columnName, + )); + } + + // Fetch raw MySQL column info for the full definition string $rows = $this->fetchAll(sprintf('SHOW FULL COLUMNS FROM %s', $this->quoteTableName($tableName))); foreach ($rows as $row) { @@ -630,8 +739,7 @@ static function ($value) { $extra = ' ' . implode(' ', $extras); if (($row['Default'] !== null)) { - $phinxTypeInfo = $this->getPhinxType($row['Type']); - $extra .= $this->getDefaultValueDefinition($row['Default'], $phinxTypeInfo['name']); + $extra .= $this->getDefaultValueDefinition($row['Default'], $targetColumn->getType()); } $definition = $row['Type'] . ' ' . $null . $extra . $comment; @@ -666,7 +774,16 @@ protected function getChangeColumnInstructions(string $tableName, string $column $this->afterClause($newColumn), ); - return new AlterInstructions([$alter]); + $instructions = new AlterInstructions([$alter]); + + if ($newColumn->getAlgorithm() !== null) { + $instructions->setAlgorithm($newColumn->getAlgorithm()); + } + if ($newColumn->getLock() !== null) { + $instructions->setLock($newColumn->getLock()); + } + + return $instructions; } /** @@ -696,44 +813,7 @@ protected function getIndexes(string $tableName): array /** * @inheritDoc */ - public function hasIndex(string $tableName, string|array $columns): bool - { - if (is_string($columns)) { - $columns = [$columns]; // str to array - } - - $columns = array_map('strtolower', $columns); - $indexes = $this->getIndexes($tableName); - - foreach ($indexes as $index) { - if ($columns == $index['columns']) { - return true; - } - } - - return false; - } - - /** - * @inheritDoc - */ - public function hasIndexByName(string $tableName, string $indexName): bool - { - $indexes = $this->getIndexes($tableName); - - foreach ($indexes as $index) { - if ($index['name'] === $indexName) { - return true; - } - } - - return false; - } - - /** - * @inheritDoc - */ - protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + protected function getAddIndexInstructions(TableMetadata $table, Index $index): AlterInstructions { $instructions = new AlterInstructions(); @@ -855,28 +935,6 @@ public function getPrimaryKey(string $tableName): array return $primaryKey; } - /** - * @inheritDoc - */ - public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool - { - $foreignKeys = $this->getForeignKeys($tableName); - $names = array_map(fn($key) => $key['name'], $foreignKeys); - if ($constraint) { - return in_array($constraint, $names, true); - } - - $columns = array_map('mb_strtolower', (array)$columns); - - foreach ($foreignKeys as $key) { - if (array_map('mb_strtolower', $key['columns']) === $columns) { - return true; - } - } - - return false; - } - /** * Get an array of foreign keys from a particular table. * @@ -894,7 +952,7 @@ protected function getForeignKeys(string $tableName): array /** * @inheritDoc */ - protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + protected function getAddForeignKeyInstructions(TableMetadata $table, ForeignKey $foreignKey): AlterInstructions { $alter = sprintf( 'ADD %s', @@ -953,360 +1011,53 @@ protected function getDropForeignKeyByColumnsInstructions(string $tableName, arr } /** - * {@inheritDoc} + * Get an array of check constraints from a particular table. * - * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException + * @param string $tableName Table name + * @return array */ - public function getSqlType(Literal|string $type, ?int $limit = null): array + protected function getCheckConstraints(string $tableName): array { - $type = (string)$type; - switch ($type) { - case static::PHINX_TYPE_FLOAT: - case static::PHINX_TYPE_DOUBLE: - case static::PHINX_TYPE_DECIMAL: - case static::PHINX_TYPE_DATE: - case static::PHINX_TYPE_ENUM: - case static::PHINX_TYPE_SET: - case static::PHINX_TYPE_JSON: - // Geospatial database types - case static::PHINX_TYPE_GEOMETRY: - case static::PHINX_TYPE_POINT: - case static::PHINX_TYPE_LINESTRING: - case static::PHINX_TYPE_POLYGON: - return ['name' => $type]; - case static::PHINX_TYPE_DATETIME: - case static::PHINX_TYPE_TIMESTAMP: - case static::PHINX_TYPE_TIME: - return ['name' => $type, 'limit' => $limit]; - case static::PHINX_TYPE_STRING: - return ['name' => 'varchar', 'limit' => $limit ?: 255]; - case static::PHINX_TYPE_CHAR: - return ['name' => 'char', 'limit' => $limit ?: 255]; - case static::PHINX_TYPE_TEXT: - if ($limit) { - $sizes = [ - // Order matters! Size must always be tested from longest to shortest! - 'longtext' => static::TEXT_LONG, - 'mediumtext' => static::TEXT_MEDIUM, - 'text' => static::TEXT_REGULAR, - 'tinytext' => static::TEXT_SMALL, - ]; - foreach ($sizes as $name => $length) { - if ($limit >= $length) { - return ['name' => $name]; - } - } - } - - return ['name' => 'text']; - case static::PHINX_TYPE_BINARY: - if ($limit === null) { - $limit = 255; - } - - if ($limit > 255) { - return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit); - } - - return ['name' => 'binary', 'limit' => $limit]; - case static::PHINX_TYPE_BINARYUUID: - return ['name' => 'binary', 'limit' => 16]; - case static::PHINX_TYPE_VARBINARY: - if ($limit === null) { - $limit = 255; - } - - if ($limit > 255) { - return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit); - } - - return ['name' => 'varbinary', 'limit' => $limit]; - case static::PHINX_TYPE_BLOB: - if ($limit !== null) { - // Rework this part as the chosen types were always UNDER the required length - $sizes = [ - 'tinyblob' => static::BLOB_SMALL, - 'blob' => static::BLOB_REGULAR, - 'mediumblob' => static::BLOB_MEDIUM, - ]; - - foreach ($sizes as $name => $length) { - if ($limit <= $length) { - return ['name' => $name]; - } - } - - // For more length requirement, the longblob is used - return ['name' => 'longblob']; - } - - // If not limit is provided, fallback on blob - return ['name' => 'blob']; - case static::PHINX_TYPE_TINYBLOB: - // Automatically reprocess blob type to ensure that correct blob subtype is selected given provided limit - return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit ?: static::BLOB_TINY); - case static::PHINX_TYPE_MEDIUMBLOB: - // Automatically reprocess blob type to ensure that correct blob subtype is selected given provided limit - return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit ?: static::BLOB_MEDIUM); - case static::PHINX_TYPE_LONGBLOB: - // Automatically reprocess blob type to ensure that correct blob subtype is selected given provided limit - return $this->getSqlType(static::PHINX_TYPE_BLOB, $limit ?: static::BLOB_LONG); - case static::PHINX_TYPE_BIT: - return ['name' => 'bit', 'limit' => $limit ?: 64]; - case static::PHINX_TYPE_BIG_INTEGER: - if ($limit === static::INT_BIG) { - $limit = static::INT_DISPLAY_BIG; - } - - return ['name' => 'bigint', 'limit' => $limit ?: 20]; - case static::PHINX_TYPE_MEDIUM_INTEGER: - if ($limit === static::INT_MEDIUM) { - $limit = static::INT_DISPLAY_MEDIUM; - } - - return ['name' => 'mediumint', 'limit' => $limit ?: 8]; - case static::PHINX_TYPE_SMALL_INTEGER: - if ($limit === static::INT_SMALL) { - $limit = static::INT_DISPLAY_SMALL; - } - - return ['name' => 'smallint', 'limit' => $limit ?: 6]; - case static::PHINX_TYPE_TINY_INTEGER: - if ($limit === static::INT_TINY) { - $limit = static::INT_DISPLAY_TINY; - } - - return ['name' => 'tinyint', 'limit' => $limit ?: 4]; - case static::PHINX_TYPE_INTEGER: - if ($limit && $limit >= static::INT_TINY) { - $sizes = [ - // Order matters! Size must always be tested from longest to shortest! - 'bigint' => static::INT_BIG, - 'int' => static::INT_REGULAR, - 'mediumint' => static::INT_MEDIUM, - 'smallint' => static::INT_SMALL, - 'tinyint' => static::INT_TINY, - ]; - $limits = [ - 'tinyint' => static::INT_DISPLAY_TINY, - 'smallint' => static::INT_DISPLAY_SMALL, - 'mediumint' => static::INT_DISPLAY_MEDIUM, - 'int' => static::INT_DISPLAY_REGULAR, - 'bigint' => static::INT_DISPLAY_BIG, - ]; - foreach ($sizes as $name => $length) { - if ($limit >= $length) { - $def = ['name' => $name]; - if (isset($limits[$name])) { - $def['limit'] = $limits[$name]; - } - - return $def; - } - } - } elseif (!$limit) { - $limit = static::INT_DISPLAY_REGULAR; - } - - return ['name' => 'int', 'limit' => $limit]; - case static::PHINX_TYPE_BOOLEAN: - return ['name' => 'tinyint', 'limit' => 1]; - case static::PHINX_TYPE_UUID: - return ['name' => 'char', 'limit' => 36]; - case static::PHINX_TYPE_NATIVEUUID: - if (!$this->hasNativeUuid()) { - throw new UnsupportedColumnTypeException( - 'Column type "' . $type . '" is not supported by this version of MySQL.', - ); - } - - return ['name' => 'uuid']; - case static::PHINX_TYPE_YEAR: - if (!$limit || in_array($limit, [2, 4])) { - $limit = 4; - } + $dialect = $this->getSchemaDialect(); - return ['name' => 'year', 'limit' => $limit]; - default: - throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not supported by MySQL.'); - } + return $dialect->describeCheckConstraints($tableName); } /** - * Returns Phinx type by SQL type - * - * @internal param string $sqlType SQL type - * @param string $sqlTypeDef SQL Type definition - * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException - * @return array Phinx type + * @inheritDoc */ - public function getPhinxType(string $sqlTypeDef): array + protected function getAddCheckConstraintInstructions(TableMetadata $table, CheckConstraint $checkConstraint): AlterInstructions { - $matches = []; - if (!preg_match('/^([\w]+)(\(([\d]+)*(,([\d]+))*\))*(.+)*$/', $sqlTypeDef, $matches)) { - throw new UnsupportedColumnTypeException('Column type "' . $sqlTypeDef . '" is not supported by MySQL.'); + $constraintName = $checkConstraint->getName(); + if ($constraintName === null) { + // Auto-generate constraint name if not provided + $constraintName = $table->getName() . '_chk_' . substr(md5($checkConstraint->getExpression()), 0, 8); } - $limit = null; - $scale = null; - $type = $matches[1]; - if (count($matches) > 2) { - $limit = $matches[3] ? (int)$matches[3] : null; - } - if (count($matches) > 4) { - $scale = (int)$matches[5]; - } - if ($type === 'tinyint' && $limit === 1) { - $type = static::PHINX_TYPE_BOOLEAN; - $limit = null; - } - switch ($type) { - case 'varchar': - $type = static::PHINX_TYPE_STRING; - if ($limit === 255) { - $limit = null; - } - break; - case 'char': - $type = static::PHINX_TYPE_CHAR; - if ($limit === 255) { - $limit = null; - } - if ($limit === 36) { - $type = static::PHINX_TYPE_UUID; - } - break; - case 'tinyint': - $type = static::PHINX_TYPE_TINY_INTEGER; - break; - case 'smallint': - $type = static::PHINX_TYPE_SMALL_INTEGER; - break; - case 'mediumint': - $type = static::PHINX_TYPE_MEDIUM_INTEGER; - break; - case 'int': - $type = static::PHINX_TYPE_INTEGER; - break; - case 'bigint': - $type = static::PHINX_TYPE_BIG_INTEGER; - break; - case 'bit': - $type = static::PHINX_TYPE_BIT; - if ($limit === 64) { - $limit = null; - } - break; - case 'blob': - $type = static::PHINX_TYPE_BLOB; - $limit = static::BLOB_REGULAR; - break; - case 'tinyblob': - $type = static::PHINX_TYPE_TINYBLOB; - $limit = static::BLOB_TINY; - break; - case 'mediumblob': - $type = static::PHINX_TYPE_MEDIUMBLOB; - $limit = static::BLOB_MEDIUM; - break; - case 'longblob': - $type = static::PHINX_TYPE_LONGBLOB; - $limit = static::BLOB_LONG; - break; - case 'tinytext': - $type = static::PHINX_TYPE_TEXT; - $limit = static::TEXT_TINY; - break; - case 'mediumtext': - $type = static::PHINX_TYPE_TEXT; - $limit = static::TEXT_MEDIUM; - break; - case 'longtext': - $type = static::PHINX_TYPE_TEXT; - $limit = static::TEXT_LONG; - break; - case 'binary': - if ($limit === null) { - $limit = 255; - } - - if ($limit > 255) { - $type = static::PHINX_TYPE_BLOB; - break; - } - - if ($limit === 16) { - $type = static::PHINX_TYPE_BINARYUUID; - } - break; - case 'uuid': - $type = static::PHINX_TYPE_NATIVEUUID; - $limit = null; - break; - } + $alter = sprintf( + 'ADD CONSTRAINT %s CHECK (%s)', + $this->quoteColumnName($constraintName), + $checkConstraint->getExpression(), + ); - try { - // Call this to check if parsed type is supported. - $this->getSqlType($type, $limit); - } catch (UnsupportedColumnTypeException $e) { - $type = Literal::from($type); - } + return new AlterInstructions([$alter]); + } - $phinxType = [ - 'name' => $type, - 'limit' => $limit, - 'scale' => $scale, - ]; + /** + * @inheritDoc + */ + protected function getDropCheckConstraintInstructions(string $tableName, string $constraintName): AlterInstructions + { + // MariaDB uses DROP CONSTRAINT, MySQL uses DROP CHECK + $keyword = $this->isMariaDb() ? 'CONSTRAINT' : 'CHECK'; - if ($type === static::PHINX_TYPE_ENUM || $type === static::PHINX_TYPE_SET) { - $values = trim($matches[6], '()'); - $phinxType['values'] = []; - $opened = false; - $escaped = false; - $wasEscaped = false; - $value = ''; - $valuesLength = strlen($values); - for ($i = 0; $i < $valuesLength; $i++) { - $char = $values[$i]; - if ($char === "'" && !$opened) { - $opened = true; - } elseif ( - !$escaped - && ($i + 1) < $valuesLength - && ( - $char === "'" && $values[$i + 1] === "'" - || $char === '\\' && $values[$i + 1] === '\\' - ) - ) { - $escaped = true; - } elseif ($char === "'" && $opened && !$escaped) { - $phinxType['values'][] = $value; - $value = ''; - $opened = false; - } elseif (($char === "'" || $char === '\\') && $opened && $escaped) { - $value .= $char; - $escaped = false; - $wasEscaped = true; - } elseif ($opened) { - if ($values[$i - 1] === '\\' && !$wasEscaped) { - if ($char === 'n') { - $char = "\n"; - } elseif ($char === 'r') { - $char = "\r"; - } elseif ($char === 't') { - $char = "\t"; - } - if ($values[$i] !== $char) { - $value = substr($value, 0, strlen($value) - 1); - } - } - $value .= $char; - $wasEscaped = false; - } - } - } + $alter = sprintf( + 'DROP %s %s', + $keyword, + $this->quoteColumnName($constraintName), + ); - return $phinxType; + return new AlterInstructions([$alter]); } /** @@ -1334,11 +1085,12 @@ public function createDatabase(string $name, array $options = []): void */ public function hasDatabase(string $name): bool { - $rows = $this->query( - 'SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?', - [$name], - )->fetchAll('assoc'); + $query = $this->getSelectBuilder() + ->select(['SCHEMA_NAME']) + ->from('INFORMATION_SCHEMA.SCHEMATA') + ->where(['SCHEMA_NAME' => $name]); + $rows = $query->execute()->fetchAll('assoc'); foreach ($rows as $row) { if ($row) { return true; @@ -1436,7 +1188,7 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string foreach ($foreignKey->getReferencedColumns() as $column) { $refColumnNames[] = $this->quoteColumnName($column); } - $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()->getName()) . ' (' . implode(',', $refColumnNames) . ')'; + $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()) . ' (' . implode(',', $refColumnNames) . ')'; $onDelete = $foreignKey->getOnDelete(); if ($onDelete) { $def .= ' ON DELETE ' . $onDelete; @@ -1459,12 +1211,236 @@ public function getColumnTypes(): array $types = array_merge(parent::getColumnTypes(), static::$specificColumnTypes); if ($this->hasNativeUuid()) { - $types[] = self::PHINX_TYPE_NATIVEUUID; + $types[] = self::TYPE_NATIVE_UUID; } return $types; } + /** + * Get the default encoding for the current database. + * + * @return string The default encoding + */ + public function getDefaultCollation(): string + { + $connection = $this->getConnection(); + $connectionConfig = $connection->config(); + + $query = $this->getSelectBuilder() + ->select(['DEFAULT_COLLATION_NAME']) + ->from('INFORMATION_SCHEMA.SCHEMATA') + ->where(['SCHEMA_NAME' => $connectionConfig['database']]); + $row = $query->execute()->fetch('assoc'); + + return $row['DEFAULT_COLLATION_NAME'] ?? ''; + } + + /** + * Gets the MySQL Partition Definition SQL. + * + * @param \Migrations\Db\Table\Partition $partition Partition configuration + * @return string + */ + protected function getPartitionSqlDefinition(Partition $partition): string + { + $type = $partition->getType(); + $columns = $partition->getColumns(); + + // Build column list or expression + if ($columns instanceof Literal) { + $columnsSql = (string)$columns; + } else { + $columnsSql = implode(', ', array_map(fn($col) => $this->quoteColumnName($col), $columns)); + } + + $sql = sprintf('PARTITION BY %s (%s)', $type, $columnsSql); + + // For HASH/KEY with count + if (in_array($type, [Partition::TYPE_HASH, Partition::TYPE_KEY], true)) { + $count = $partition->getCount(); + if ($count !== null) { + $sql .= sprintf(' PARTITIONS %d', $count); + } + + return $sql; + } + + // For RANGE/LIST with definitions + $definitions = $partition->getDefinitions(); + if ($definitions) { + $sql .= ' ('; + $parts = []; + foreach ($definitions as $definition) { + $parts[] = $this->getPartitionDefinitionSql($type, $definition); + } + $sql .= implode(', ', $parts); + $sql .= ')'; + } + + return $sql; + } + + /** + * Gets the SQL for a single partition definition. + * + * @param string $type Partition type + * @param \Migrations\Db\Table\PartitionDefinition $definition Partition definition + * @return string + */ + protected function getPartitionDefinitionSql(string $type, PartitionDefinition $definition): string + { + $sql = 'PARTITION ' . $this->quoteColumnName($definition->getName()); + + $value = $definition->getValue(); + $isRangeType = in_array($type, [Partition::TYPE_RANGE, Partition::TYPE_RANGE_COLUMNS], true); + $isListType = in_array($type, [Partition::TYPE_LIST, Partition::TYPE_LIST_COLUMNS], true); + + if ($isRangeType) { + $sql .= ' VALUES LESS THAN '; + if ($value === 'MAXVALUE' || $value === Partition::TYPE_RANGE . '_MAXVALUE') { + $sql .= 'MAXVALUE'; + } elseif (is_array($value)) { + $sql .= '(' . implode(', ', array_map(fn($v) => $this->quotePartitionValue($v), $value)) . ')'; + } else { + $sql .= '(' . $this->quotePartitionValue($value) . ')'; + } + } elseif ($isListType) { + $sql .= ' VALUES IN ('; + if (is_array($value)) { + $sql .= implode(', ', array_map(fn($v) => $this->quotePartitionValue($v), $value)); + } else { + $sql .= $this->quotePartitionValue($value); + } + $sql .= ')'; + } + + if ($definition->getComment()) { + $sql .= ' COMMENT = ' . $this->quoteString($definition->getComment()); + } + + return $sql; + } + + /** + * Quote a partition boundary value. + * + * @param mixed $value The value to quote + * @return string + */ + protected function quotePartitionValue(mixed $value): string + { + if ($value === null) { + return 'NULL'; + } + if (is_int($value) || is_float($value)) { + return (string)$value; + } + if ($value === 'MAXVALUE') { + return 'MAXVALUE'; + } + + return $this->quoteString((string)$value); + } + + /** + * Get instructions for adding partitioning to an existing table. + * + * @param \Migrations\Db\Table\TableMetadata $table The table + * @param \Migrations\Db\Table\Partition $partition The partition configuration + * @return \Migrations\Db\AlterInstructions + */ + protected function getSetPartitioningInstructions(TableMetadata $table, Partition $partition): AlterInstructions + { + $sql = $this->getPartitionSqlDefinition($partition); + + return new AlterInstructions([$sql]); + } + + /** + * Get instructions for adding multiple partitions to an existing table. + * + * MySQL requires all partitions in a single ADD PARTITION clause: + * ADD PARTITION (PARTITION p1 ..., PARTITION p2 ...) + * + * @param \Migrations\Db\Table\TableMetadata $table The table + * @param array<\Migrations\Db\Table\PartitionDefinition> $partitions The partitions to add + * @return \Migrations\Db\AlterInstructions + */ + protected function getAddPartitionsInstructions(TableMetadata $table, array $partitions): AlterInstructions + { + if (empty($partitions)) { + return new AlterInstructions(); + } + + $partitionDefs = []; + foreach ($partitions as $partition) { + $partitionDefs[] = $this->getAddPartitionSql($partition); + } + + $sql = 'ADD PARTITION (' . implode(', ', $partitionDefs) . ')'; + + return new AlterInstructions([$sql]); + } + + /** + * Get instructions for dropping multiple partitions from an existing table. + * + * MySQL allows dropping multiple partitions in a single statement: + * DROP PARTITION p1, p2, p3 + * + * @param string $tableName The table name + * @param array $partitionNames The partition names to drop + * @return \Migrations\Db\AlterInstructions + */ + protected function getDropPartitionsInstructions(string $tableName, array $partitionNames): AlterInstructions + { + if (empty($partitionNames)) { + return new AlterInstructions(); + } + + $quotedNames = array_map(fn($name) => $this->quoteColumnName($name), $partitionNames); + $sql = 'DROP PARTITION ' . implode(', ', $quotedNames); + + return new AlterInstructions([$sql]); + } + + /** + * Generate the SQL definition for a single partition when adding to existing table. + * + * This method is used when adding partitions to an existing table and must + * infer the partition type from the value format since we don't have table metadata. + * + * @param \Migrations\Db\Table\PartitionDefinition $partition The partition definition + * @return string + */ + protected function getAddPartitionSql(PartitionDefinition $partition): string + { + $value = $partition->getValue(); + $sql = 'PARTITION ' . $this->quoteColumnName($partition->getName()); + + // Detect RANGE vs LIST based on value type (simplified heuristic) + if ($value === 'MAXVALUE' || is_scalar($value)) { + // Likely RANGE + if ($value === 'MAXVALUE') { + $sql .= ' VALUES LESS THAN MAXVALUE'; + } else { + $sql .= ' VALUES LESS THAN (' . $this->quotePartitionValue($value) . ')'; + } + } elseif (is_array($value)) { + // Likely LIST + $sql .= ' VALUES IN ('; + $sql .= implode(', ', array_map(fn($v) => $this->quotePartitionValue($v), $value)); + $sql .= ')'; + } + + if ($partition->getComment()) { + $sql .= ' COMMENT = ' . $this->quoteString($partition->getComment()); + } + + return $sql; + } + /** * Whether the server has a native uuid type. * (MariaDB 10.7.0+) @@ -1482,4 +1458,107 @@ protected function hasNativeUuid(): bool return version_compare($version, '10.7', '>='); } + + /** + * Whether the server is MariaDB (as opposed to MySQL). + * + * @return bool + */ + protected function isMariaDb(): bool + { + // Prevent infinite connect() loop when MysqlAdapter is used as a stub. + if ($this->connection === null || !$this->getOption('connection')) { + return false; + } + $connection = $this->getConnection(); + $version = $connection->getDriver()->version(); + + return stripos($version, 'mariadb') !== false; + } + + /** + * {@inheritDoc} + * + * Overridden to support ALGORITHM and LOCK clauses from AlterInstructions. + * + * @param string $tableName The table name + * @param \Migrations\Db\AlterInstructions $instructions The alter instructions + * @throws \InvalidArgumentException + * @return void + */ + protected function executeAlterSteps(string $tableName, AlterInstructions $instructions): void + { + $algorithm = $instructions->getAlgorithm(); + $lock = $instructions->getLock(); + + if ($algorithm === null && $lock === null) { + parent::executeAlterSteps($tableName, $instructions); + + return; + } + + $algorithmLockClause = ''; + $upperAlgorithm = null; + $upperLock = null; + + if ($algorithm !== null) { + $upperAlgorithm = strtoupper($algorithm); + $validAlgorithms = [ + self::ALGORITHM_DEFAULT, + self::ALGORITHM_INSTANT, + self::ALGORITHM_INPLACE, + self::ALGORITHM_COPY, + ]; + if (!in_array($upperAlgorithm, $validAlgorithms, true)) { + throw new InvalidArgumentException(sprintf( + 'Invalid algorithm "%s". Valid options: %s', + $algorithm, + implode(', ', $validAlgorithms), + )); + } + $algorithmLockClause .= ', ALGORITHM=' . $upperAlgorithm; + } + + if ($lock !== null) { + $upperLock = strtoupper($lock); + $validLocks = [ + self::LOCK_DEFAULT, + self::LOCK_NONE, + self::LOCK_SHARED, + self::LOCK_EXCLUSIVE, + ]; + if (!in_array($upperLock, $validLocks, true)) { + throw new InvalidArgumentException(sprintf( + 'Invalid lock "%s". Valid options: %s', + $lock, + implode(', ', $validLocks), + )); + } + $algorithmLockClause .= ', LOCK=' . $upperLock; + } + + if ($upperAlgorithm === self::ALGORITHM_INSTANT && $upperLock !== null && $upperLock !== self::LOCK_DEFAULT) { + throw new InvalidArgumentException( + 'ALGORITHM=INSTANT cannot be combined with LOCK=NONE, LOCK=SHARED, or LOCK=EXCLUSIVE. ' . + 'Either use ALGORITHM=INSTANT alone, or use ALGORITHM=INSTANT with LOCK=DEFAULT.', + ); + } + + $alterTemplate = sprintf('ALTER TABLE %s %%s', $this->quoteTableName($tableName)); + + if ($instructions->getAlterParts()) { + $alter = sprintf($alterTemplate, implode(', ', $instructions->getAlterParts()) . $algorithmLockClause); + $this->execute($alter); + } + + $state = []; + foreach ($instructions->getPostSteps() as $instruction) { + if (is_callable($instruction)) { + $state = $instruction($state); + continue; + } + + $this->execute($instruction); + } + } } diff --git a/src/Db/Adapter/PhinxAdapter.php b/src/Db/Adapter/PhinxAdapter.php deleted file mode 100644 index 016769395..000000000 --- a/src/Db/Adapter/PhinxAdapter.php +++ /dev/null @@ -1,828 +0,0 @@ -getName(), - $phinxTable->getOptions(), - ); - - return $table; - } - - /** - * Convert a phinx column into a migrations object - * - * @param \Phinx\Db\Table\Column $phinxColumn The column to convert. - * @return \Migrations\Db\Table\Column - */ - protected function convertColumn(PhinxColumn $phinxColumn): Column - { - $column = new Column(); - $attrs = [ - 'name', 'null', 'default', 'identity', - 'generated', 'seed', 'increment', 'scale', - 'after', 'update', 'comment', 'signed', - 'timezone', 'properties', 'collation', - 'encoding', 'srid', 'values', 'limit', - ]; - foreach ($attrs as $attr) { - $get = 'get' . ucfirst($attr); - $set = 'set' . ucfirst($attr); - try { - $value = $phinxColumn->{$get}(); - } catch (RuntimeException $e) { - $value = null; - } - if ($value !== null) { - $column->{$set}($value); - } - } - try { - $type = $phinxColumn->getType(); - } catch (RuntimeException $e) { - $type = null; - } - if ($type instanceof PhinxLiteral) { - $type = Literal::from((string)$type); - } - if ($type) { - $column->setType($type); - } - - return $column; - } - - /** - * Convert a migrations column into a phinx object - * - * @param \Migrations\Db\Table\Column $column The column to convert. - * @return \Phinx\Db\Table\Column - */ - protected function convertColumnToPhinx(Column $column): PhinxColumn - { - $phinx = new PhinxColumn(); - $attrs = [ - 'name', 'type', 'null', 'default', 'identity', - 'generated', 'seed', 'increment', 'scale', - 'after', 'update', 'comment', 'signed', - 'timezone', 'properties', 'collation', - 'encoding', 'srid', 'values', 'limit', - ]; - foreach ($attrs as $attr) { - $get = 'get' . ucfirst($attr); - $set = 'set' . ucfirst($attr); - $value = $column->{$get}(); - $value = $column->{$get}(); - if ($value !== null) { - $phinx->{$set}($value); - } - } - - return $phinx; - } - - /** - * Convert a migrations Index into a phinx object - * - * @param \Phinx\Db\Table\Index $phinxIndex The index to convert. - * @return \Migrations\Db\Table\Index - */ - protected function convertIndex(PhinxIndex $phinxIndex): Index - { - $index = new Index(); - $attrs = [ - 'name', 'columns', 'type', 'limit', 'order', - 'include', - ]; - foreach ($attrs as $attr) { - $get = 'get' . ucfirst($attr); - $set = 'set' . ucfirst($attr); - try { - $value = $phinxIndex->{$get}(); - } catch (RuntimeException $e) { - $value = null; - } - if ($value !== null) { - $index->{$set}($value); - } - } - - return $index; - } - - /** - * Convert a phinx ForeignKey into a migrations object - * - * @param \Phinx\Db\Table\ForeignKey $phinxKey The index to convert. - * @return \Migrations\Db\Table\ForeignKey - */ - protected function convertForeignKey(PhinxForeignKey $phinxKey): ForeignKey - { - $foreignKey = new ForeignKey(); - $attrs = [ - 'columns', 'referencedColumns', 'onDelete', 'onUpdate', 'constraint', - ]; - - foreach ($attrs as $attr) { - $get = 'get' . ucfirst($attr); - $set = 'set' . ucfirst($attr); - try { - $value = $phinxKey->{$get}(); - } catch (RuntimeException $e) { - $value = null; - } - if ($value !== null) { - $foreignKey->{$set}($value); - } - } - - try { - $referenced = $phinxKey->getReferencedTable(); - } catch (RuntimeException $e) { - $referenced = null; - } - if ($referenced) { - $foreignKey->setReferencedTable($this->convertTable($referenced)); - } - - return $foreignKey; - } - - /** - * Convert a phinx Action into a migrations object - * - * @param \Phinx\Db\Action\Action $phinxAction The index to convert. - * @return \Migrations\Db\Action\Action - */ - protected function convertAction(PhinxAction $phinxAction): Action - { - $action = null; - if ($phinxAction instanceof PhinxAddColumn) { - $action = new AddColumn( - $this->convertTable($phinxAction->getTable()), - $this->convertColumn($phinxAction->getColumn()), - ); - } elseif ($phinxAction instanceof PhinxAddForeignKey) { - $action = new AddForeignKey( - $this->convertTable($phinxAction->getTable()), - $this->convertForeignKey($phinxAction->getForeignKey()), - ); - } elseif ($phinxAction instanceof PhinxAddIndex) { - $action = new AddIndex( - $this->convertTable($phinxAction->getTable()), - $this->convertIndex($phinxAction->getIndex()), - ); - } elseif ($phinxAction instanceof PhinxChangeColumn) { - $action = new ChangeColumn( - $this->convertTable($phinxAction->getTable()), - $phinxAction->getColumnName(), - $this->convertColumn($phinxAction->getColumn()), - ); - } elseif ($phinxAction instanceof PhinxChangeComment) { - $action = new ChangeComment( - $this->convertTable($phinxAction->getTable()), - $phinxAction->getNewComment(), - ); - } elseif ($phinxAction instanceof PhinxChangePrimaryKey) { - $action = new ChangePrimaryKey( - $this->convertTable($phinxAction->getTable()), - $phinxAction->getNewColumns(), - ); - } elseif ($phinxAction instanceof PhinxCreateTable) { - $action = new CreateTable( - $this->convertTable($phinxAction->getTable()), - ); - } elseif ($phinxAction instanceof PhinxDropForeignKey) { - $action = new DropForeignKey( - $this->convertTable($phinxAction->getTable()), - $this->convertForeignKey($phinxAction->getForeignKey()), - ); - } elseif ($phinxAction instanceof PhinxDropIndex) { - $action = new DropIndex( - $this->convertTable($phinxAction->getTable()), - $this->convertIndex($phinxAction->getIndex()), - ); - } elseif ($phinxAction instanceof PhinxDropTable) { - $action = new DropTable( - $this->convertTable($phinxAction->getTable()), - ); - } elseif ($phinxAction instanceof PhinxRemoveColumn) { - $action = new RemoveColumn( - $this->convertTable($phinxAction->getTable()), - $this->convertColumn($phinxAction->getColumn()), - ); - } elseif ($phinxAction instanceof PhinxRenameColumn) { - $action = new RenameColumn( - $this->convertTable($phinxAction->getTable()), - $this->convertColumn($phinxAction->getColumn()), - $phinxAction->getNewName(), - ); - } elseif ($phinxAction instanceof PhinxRenameTable) { - $action = new RenameTable( - $this->convertTable($phinxAction->getTable()), - $phinxAction->getNewName(), - ); - } - if (!$action) { - throw new RuntimeException('Unable to map action of type ' . get_class($phinxAction)); - } - - return $action; - } - - /** - * Convert a phinx Literal into a migrations object - * - * @param \Phinx\Util\Literal|string $phinxLiteral The literal to convert. - * @return \Migrations\Db\Literal|string - */ - protected function convertLiteral(PhinxLiteral|string $phinxLiteral): Literal|string - { - if (is_string($phinxLiteral)) { - return $phinxLiteral; - } - - return new Literal((string)$phinxLiteral); - } - - /** - * @inheritDoc - */ - public function __construct(AdapterInterface $adapter) - { - $this->adapter = $adapter; - } - - /** - * @inheritDoc - */ - public function setOptions(array $options): PhinxAdapterInterface - { - $this->adapter->setOptions($options); - - return $this; - } - - /** - * @inheritDoc - */ - public function getOptions(): array - { - return $this->adapter->getOptions(); - } - - /** - * @inheritDoc - */ - public function hasOption(string $name): bool - { - return $this->adapter->hasOption($name); - } - - /** - * @inheritDoc - */ - public function getOption(string $name): mixed - { - return $this->adapter->getOption($name); - } - - /** - * @inheritDoc - */ - public function setInput(InputInterface $input): PhinxAdapterInterface - { - throw new RuntimeException('Using setInput() on Adapters is no longer supported'); - } - - /** - * @inheritDoc - */ - public function getInput(): InputInterface - { - throw new RuntimeException('Using getInput() on Adapters is no longer supported'); - } - - /** - * @inheritDoc - */ - public function setOutput(OutputInterface $output): PhinxAdapterInterface - { - throw new RuntimeException('Using setOutput() on Adapters is no longer supported'); - } - - /** - * @inheritDoc - */ - public function getOutput(): OutputInterface - { - throw new RuntimeException('Using getOutput() on Adapters is no longer supported'); - } - - /** - * @inheritDoc - */ - public function getColumnForType(string $columnName, string $type, array $options): PhinxColumn - { - $column = $this->adapter->getColumnForType($columnName, $type, $options); - - return $this->convertColumnToPhinx($column); - } - - /** - * @inheritDoc - */ - public function connect(): void - { - $this->adapter->connect(); - } - - /** - * @inheritDoc - */ - public function disconnect(): void - { - $this->adapter->disconnect(); - } - - /** - * @inheritDoc - */ - public function execute(string $sql, array $params = []): int - { - return $this->adapter->execute($sql, $params); - } - - /** - * @inheritDoc - */ - public function query(string $sql, array $params = []): mixed - { - return $this->adapter->query($sql, $params); - } - - /** - * @inheritDoc - */ - public function insert(PhinxTable $table, array $row): void - { - $this->adapter->insert($this->convertTable($table), $row); - } - - /** - * @inheritDoc - */ - public function bulkinsert(PhinxTable $table, array $rows): void - { - $this->adapter->bulkinsert($this->convertTable($table), $rows); - } - - /** - * @inheritDoc - */ - public function fetchRow(string $sql): array|false - { - return $this->adapter->fetchRow($sql); - } - - /** - * @inheritDoc - */ - public function fetchAll(string $sql): array - { - return $this->adapter->fetchAll($sql); - } - - /** - * @inheritDoc - */ - public function getVersions(): array - { - return $this->adapter->getVersions(); - } - - /** - * @inheritDoc - */ - public function getVersionLog(): array - { - return $this->adapter->getVersionLog(); - } - - /** - * @inheritDoc - */ - public function migrated(PhinxMigrationInterface $migration, string $direction, string $startTime, string $endTime): PhinxAdapterInterface - { - $wrapped = new MigrationAdapter($migration, $migration->getVersion()); - $this->adapter->migrated($wrapped, $direction, $startTime, $endTime); - - return $this; - } - - /** - * @inheritDoc - */ - public function toggleBreakpoint(PhinxMigrationInterface $migration): PhinxAdapterInterface - { - $wrapped = new MigrationAdapter($migration, $migration->getVersion()); - $this->adapter->toggleBreakpoint($wrapped); - - return $this; - } - - /** - * @inheritDoc - */ - public function resetAllBreakpoints(): int - { - return $this->adapter->resetAllBreakpoints(); - } - - /** - * @inheritDoc - */ - public function setBreakpoint(PhinxMigrationInterface $migration): PhinxAdapterInterface - { - $wrapped = new MigrationAdapter($migration, $migration->getVersion()); - $this->adapter->setBreakpoint($wrapped); - - return $this; - } - - /** - * @inheritDoc - */ - public function unsetBreakpoint(PhinxMigrationInterface $migration): PhinxAdapterInterface - { - $wrapped = new MigrationAdapter($migration, $migration->getVersion()); - $this->adapter->unsetBreakpoint($wrapped); - - return $this; - } - - /** - * @inheritDoc - */ - public function createSchemaTable(): void - { - $this->adapter->createSchemaTable(); - } - - /** - * @inheritDoc - */ - public function getColumnTypes(): array - { - return $this->adapter->getColumnTypes(); - } - - /** - * @inheritDoc - */ - public function isValidColumnType(PhinxColumn $column): bool - { - return $this->adapter->isValidColumnType($this->convertColumn($column)); - } - - /** - * @inheritDoc - */ - public function hasTransactions(): bool - { - return $this->adapter->hasTransactions(); - } - - /** - * @inheritDoc - */ - public function beginTransaction(): void - { - $this->adapter->beginTransaction(); - } - - /** - * @inheritDoc - */ - public function commitTransaction(): void - { - $this->adapter->commitTransaction(); - } - - /** - * @inheritDoc - */ - public function rollbackTransaction(): void - { - $this->adapter->rollbackTransaction(); - } - - /** - * @inheritDoc - */ - public function quoteTableName(string $tableName): string - { - return $this->adapter->quoteTableName($tableName); - } - - /** - * @inheritDoc - */ - public function quoteColumnName(string $columnName): string - { - return $this->adapter->quoteColumnName($columnName); - } - - /** - * @inheritDoc - */ - public function hasTable(string $tableName): bool - { - return $this->adapter->hasTable($tableName); - } - - /** - * @inheritDoc - */ - public function createTable(PhinxTable $table, array $columns = [], array $indexes = []): void - { - $columns = array_map(function ($col) { - return $this->convertColumn($col); - }, $columns); - $indexes = array_map(function ($ind) { - return $this->convertIndex($ind); - }, $indexes); - $this->adapter->createTable($this->convertTable($table), $columns, $indexes); - } - - /** - * @inheritDoc - */ - public function getColumns(string $tableName): array - { - $columns = $this->adapter->getColumns($tableName); - - return array_map(function ($col) { - return $this->convertColumnToPhinx($col); - }, $columns); - } - - /** - * @inheritDoc - */ - public function hasColumn(string $tableName, string $columnName): bool - { - return $this->adapter->hasColumn($tableName, $columnName); - } - - /** - * @inheritDoc - */ - public function hasIndex(string $tableName, string|array $columns): bool - { - return $this->adapter->hasIndex($tableName, $columns); - } - - /** - * @inheritDoc - */ - public function hasIndexByName(string $tableName, string $indexName): bool - { - return $this->adapter->hasIndexByName($tableName, $indexName); - } - - /** - * @inheritDoc - */ - public function hasPrimaryKey(string $tableName, $columns, ?string $constraint = null): bool - { - return $this->adapter->hasPrimaryKey($tableName, $columns, $constraint); - } - - /** - * @inheritDoc - */ - public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool - { - return $this->adapter->hasForeignKey($tableName, $columns, $constraint); - } - - /** - * @inheritDoc - */ - public function getSqlType(PhinxLiteral|string $type, ?int $limit = null): array - { - return $this->adapter->getSqlType($this->convertLiteral($type), $limit); - } - - /** - * @inheritDoc - */ - public function createDatabase(string $name, array $options = []): void - { - $this->adapter->createDatabase($name, $options); - } - - /** - * @inheritDoc - */ - public function hasDatabase(string $name): bool - { - return $this->adapter->hasDatabase($name); - } - - /** - * @inheritDoc - */ - public function dropDatabase(string $name): void - { - $this->adapter->dropDatabase($name); - } - - /** - * @inheritDoc - */ - public function createSchema(string $schemaName = 'public'): void - { - $this->adapter->createSchema($schemaName); - } - - /** - * @inheritDoc - */ - public function dropSchema(string $schemaName): void - { - $this->adapter->dropSchema($schemaName); - } - - /** - * @inheritDoc - */ - public function truncateTable(string $tableName): void - { - $this->adapter->truncateTable($tableName); - } - - /** - * @inheritDoc - */ - public function castToBool($value): mixed - { - return $this->adapter->castToBool($value); - } - - /** - * @return \Cake\Database\Connection - */ - public function getConnection(): Connection - { - return $this->adapter->getConnection(); - } - - /** - * @inheritDoc - */ - public function executeActions(PhinxTable $table, array $actions): void - { - $actions = array_map(function ($act) { - return $this->convertAction($act); - }, $actions); - $this->adapter->executeActions($this->convertTable($table), $actions); - } - - /** - * @inheritDoc - */ - public function getAdapterType(): string - { - return $this->adapter->getAdapterType(); - } - - /** - * @inheritDoc - */ - public function getQueryBuilder(string $type): Query - { - return $this->adapter->getQueryBuilder($type); - } - - /** - * @inheritDoc - */ - public function getSelectBuilder(): SelectQuery - { - return $this->adapter->getSelectBuilder(); - } - - /** - * @inheritDoc - */ - public function getInsertBuilder(): InsertQuery - { - return $this->adapter->getInsertBuilder(); - } - - /** - * @inheritDoc - */ - public function getUpdateBuilder(): UpdateQuery - { - return $this->adapter->getUpdateBuilder(); - } - - /** - * @inheritDoc - */ - public function getDeleteBuilder(): DeleteQuery - { - return $this->adapter->getDeleteBuilder(); - } - - /** - * @inheritDoc - */ - public function getCakeConnection(): Connection - { - return $this->adapter->getConnection(); - } -} diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index bb204b688..5251ce4a1 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -14,12 +14,16 @@ use Cake\I18n\DateTime; use InvalidArgumentException; use Migrations\Db\AlterInstructions; +use Migrations\Db\InsertMode; use Migrations\Db\Literal; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; -use Migrations\Db\Table\Table; -use Phinx\Util\Literal as PhinxLiteral; +use Migrations\Db\Table\Partition; +use Migrations\Db\Table\PartitionDefinition; +use Migrations\Db\Table\TableMetadata; +use RuntimeException; class PostgresAdapter extends AbstractAdapter { @@ -35,14 +39,14 @@ class PostgresAdapter extends AbstractAdapter * @var string[] */ protected static array $specificColumnTypes = [ - self::PHINX_TYPE_JSON, + self::TYPE_JSON, self::PHINX_TYPE_JSONB, - self::PHINX_TYPE_CIDR, - self::PHINX_TYPE_INET, - self::PHINX_TYPE_MACADDR, - self::PHINX_TYPE_INTERVAL, - self::PHINX_TYPE_BINARYUUID, - self::PHINX_TYPE_NATIVEUUID, + self::TYPE_CIDR, + self::TYPE_INET, + self::TYPE_MACADDR, + self::TYPE_INTERVAL, + self::TYPE_BINARY_UUID, + self::TYPE_NATIVE_UUID, ]; private const GIN_INDEX_TYPE = 'gin'; @@ -99,25 +103,25 @@ public function quoteTableName(string $tableName): string */ public function hasTable(string $tableName): bool { - if ($this->hasCreatedTable($tableName)) { + // Only use the cache in dry-run mode where tables aren't actually created. + // In normal mode, always check the database to handle cases where tables + // are dropped via execute() which doesn't update the cache. + if ($this->isDryRunEnabled() && $this->hasCreatedTable($tableName)) { return true; } + $parts = $this->getSchemaName($tableName); $tableName = $parts['table']; $dialect = $this->getSchemaDialect(); - [$query, $params] = $dialect->listTablesSql(['schema' => $parts['schema']]); - - $rows = $this->query($query, $params)->fetchAll(); - $tables = array_column($rows, 0); - return in_array($tableName, $tables, true); + return $dialect->hasTable($tableName, $parts['schema']); } /** * @inheritDoc */ - public function createTable(Table $table, array $columns = [], array $indexes = []): void + public function createTable(TableMetadata $table, array $columns = [], array $indexes = []): void { $queries = []; @@ -173,6 +177,13 @@ public function createTable(Table $table, array $columns = [], array $indexes = } $sql .= ')'; + + // add partitioning clause + $partition = $table->getPartition(); + if ($partition !== null) { + $sql .= ' ' . $this->getPartitionSqlDefinition($partition); + } + $queries[] = $sql; // process column comments @@ -198,6 +209,13 @@ public function createTable(Table $table, array $columns = [], array $indexes = ); } + // create partition tables for PostgreSQL declarative partitioning + if ($partition !== null) { + foreach ($partition->getDefinitions() as $definition) { + $queries[] = $this->getPartitionTableSql($table->getName(), $partition, $definition); + } + } + foreach ($queries as $query) { $this->execute($query); } @@ -209,13 +227,13 @@ public function createTable(Table $table, array $columns = [], array $indexes = * Apply postgres specific translations between the values using migrations constants/types * and the cakephp/database constants. Over time, these can be aligned. * - * @param array $data The raw column data. - * @return array Modified column data. + * @param array $data The raw column data. + * @return array Modified column data. */ protected function mapColumnData(array $data): array { if ( - $data['type'] === self::PHINX_TYPE_TIMESTAMP && + $data['type'] === self::TYPE_TIMESTAMP && isset($data['timezone']) && $data['timezone'] === true ) { $data['type'] = 'timestamptimezone'; @@ -233,7 +251,7 @@ protected function mapColumnData(array $data): array * * @throws \InvalidArgumentException */ - protected function getChangePrimaryKeyInstructions(Table $table, array|string|null $newColumns): AlterInstructions + protected function getChangePrimaryKeyInstructions(TableMetadata $table, array|string|null $newColumns): AlterInstructions { $parts = $this->getSchemaName($table->getName()); $instructions = new AlterInstructions(); @@ -269,7 +287,7 @@ protected function getChangePrimaryKeyInstructions(Table $table, array|string|nu /** * @inheritDoc */ - protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions + protected function getChangeCommentInstructions(TableMetadata $table, ?string $newComment): AlterInstructions { $instructions = new AlterInstructions(); @@ -365,25 +383,7 @@ public function getColumns(string $tableName): array /** * @inheritDoc */ - public function hasColumn(string $tableName, string $columnName): bool - { - $parts = $this->getSchemaName($tableName); - $connection = $this->getConnection(); - $sql = 'SELECT count(*) - FROM information_schema.columns - WHERE table_schema = ? AND table_name = ? AND column_name = ?'; - - $result = $connection->execute($sql, [$parts['schema'], $parts['table'], $columnName]); - $row = $result->fetch('assoc'); - $result->closeCursor(); - - return $row['count'] > 0; - } - - /** - * @inheritDoc - */ - protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + protected function getAddColumnInstructions(TableMetadata $table, Column $column): AlterInstructions { $dialect = $this->getSchemaDialect(); @@ -456,6 +456,8 @@ protected function getChangeColumnInstructions( $columnSql = $dialect->columnDefinitionSql($this->mapColumnData($newColumn->toArray())); // Remove the column name from $columnSql $columnType = preg_replace('/^"?(?:[^"]+)"?\s+/', '', $columnSql); + // Remove generated clause + $columnType = preg_replace('/GENERATED (?:ALWAYS|BY DEFAULT) AS IDENTITY/', '', $columnType); $sql = sprintf( 'ALTER COLUMN %s TYPE %s', @@ -602,40 +604,7 @@ protected function getIndexes(string $tableName): array /** * @inheritDoc */ - public function hasIndex(string $tableName, string|array $columns): bool - { - if (is_string($columns)) { - $columns = [$columns]; - } - $indexes = $this->getIndexes($tableName); - foreach ($indexes as $index) { - if (array_diff($index['columns'], $columns) === array_diff($columns, $index['columns'])) { - return true; - } - } - - return false; - } - - /** - * @inheritDoc - */ - public function hasIndexByName(string $tableName, string $indexName): bool - { - $indexes = $this->getIndexes($tableName); - foreach ($indexes as $index) { - if ($index['name'] === $indexName || (isset($index['constraint']) && $index['constraint'] === $indexName)) { - return true; - } - } - - return false; - } - - /** - * @inheritDoc - */ - protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + protected function getAddIndexInstructions(TableMetadata $table, Index $index): AlterInstructions { $instructions = new AlterInstructions(); $instructions->addPostStep($this->getIndexSqlDefinition($index, $table->getName())); @@ -729,30 +698,6 @@ public function getPrimaryKey(string $tableName): array return ['constraint' => '', 'columns' => []]; } - /** - * @inheritDoc - */ - public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool - { - $foreignKeys = $this->getForeignKeys($tableName); - $names = array_column($foreignKeys, 'name'); - if ($constraint) { - return in_array($constraint, $names); - } - - if (is_string($columns)) { - $columns = [$columns]; - } - - foreach ($foreignKeys as $key) { - if ($key['columns'] === $columns) { - return true; - } - } - - return false; - } - /** * Get an array of foreign keys from a particular table. * @@ -770,7 +715,7 @@ protected function getForeignKeys(string $tableName): array /** * @inheritDoc */ - protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + protected function getAddForeignKeyInstructions(TableMetadata $table, ForeignKey $foreignKey): AlterInstructions { $alter = sprintf( 'ADD %s', @@ -825,145 +770,51 @@ protected function getDropForeignKeyByColumnsInstructions(string $tableName, arr } /** - * {@inheritDoc} + * Get an array of check constraints from a particular table. * - * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException + * @param string $tableName Table name + * @return array */ - public function getSqlType(Literal|string $type, ?int $limit = null): array + protected function getCheckConstraints(string $tableName): array { - $type = (string)$type; - switch ($type) { - case static::PHINX_TYPE_TEXT: - case static::PHINX_TYPE_TIME: - case static::PHINX_TYPE_DATE: - case static::PHINX_TYPE_BOOLEAN: - case static::PHINX_TYPE_JSON: - case static::PHINX_TYPE_JSONB: - case static::PHINX_TYPE_UUID: - case static::PHINX_TYPE_CIDR: - case static::PHINX_TYPE_INET: - case static::PHINX_TYPE_MACADDR: - case static::PHINX_TYPE_TIMESTAMP: - case static::PHINX_TYPE_INTEGER: - return ['name' => $type]; - case static::PHINX_TYPE_TINY_INTEGER: - return ['name' => 'smallint']; - case static::PHINX_TYPE_SMALL_INTEGER: - return ['name' => 'smallint']; - case static::PHINX_TYPE_DECIMAL: - return ['name' => $type, 'precision' => 18, 'scale' => 0]; - case static::PHINX_TYPE_DOUBLE: - return ['name' => 'double precision']; - case static::PHINX_TYPE_STRING: - return ['name' => 'character varying', 'limit' => 255]; - case static::PHINX_TYPE_CHAR: - return ['name' => 'character', 'limit' => 255]; - case static::PHINX_TYPE_BIG_INTEGER: - return ['name' => 'bigint']; - case static::PHINX_TYPE_FLOAT: - return ['name' => 'real']; - case static::PHINX_TYPE_DATETIME: - return ['name' => 'timestamp']; - case static::PHINX_TYPE_BINARYUUID: - case static::PHINX_TYPE_NATIVEUUID: - return ['name' => 'uuid']; - case static::PHINX_TYPE_BLOB: - case static::PHINX_TYPE_BINARY: - return ['name' => 'bytea']; - case static::PHINX_TYPE_INTERVAL: - return ['name' => 'interval']; - // Geospatial database types - // Spatial storage in Postgres is done via the PostGIS extension, - // which enables the use of the "geography" type in combination - // with SRID 4326. - case static::PHINX_TYPE_GEOMETRY: - return ['name' => 'geography', 'type' => 'geometry', 'srid' => 4326]; - case static::PHINX_TYPE_POINT: - return ['name' => 'geography', 'type' => 'point', 'srid' => 4326]; - case static::PHINX_TYPE_LINESTRING: - return ['name' => 'geography', 'type' => 'linestring', 'srid' => 4326]; - case static::PHINX_TYPE_POLYGON: - return ['name' => 'geography', 'type' => 'polygon', 'srid' => 4326]; - default: - if ($this->isArrayType($type)) { - return ['name' => $type]; - } - // Return array type - throw new UnsupportedColumnTypeException('Column type `' . $type . '` is not supported by Postgresql.'); - } + $dialect = $this->getSchemaDialect(); + $constraints = $dialect->describeCheckConstraints($tableName); + + return $constraints; } /** - * Returns Phinx type by SQL type - * - * @param string $sqlType SQL type - * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException - * @return string Phinx type + * @inheritDoc */ - public function getPhinxType(string $sqlType): string + protected function getAddCheckConstraintInstructions(TableMetadata $table, CheckConstraint $checkConstraint): AlterInstructions { - switch ($sqlType) { - case 'character varying': - case 'varchar': - return static::PHINX_TYPE_STRING; - case 'character': - case 'char': - return static::PHINX_TYPE_CHAR; - case 'text': - return static::PHINX_TYPE_TEXT; - case 'json': - return static::PHINX_TYPE_JSON; - case 'jsonb': - return static::PHINX_TYPE_JSONB; - case 'smallint': - return static::PHINX_TYPE_SMALL_INTEGER; - case 'int': - case 'int4': - case 'integer': - return static::PHINX_TYPE_INTEGER; - case 'decimal': - case 'numeric': - return static::PHINX_TYPE_DECIMAL; - case 'bigint': - case 'int8': - return static::PHINX_TYPE_BIG_INTEGER; - case 'real': - case 'float4': - return static::PHINX_TYPE_FLOAT; - case 'double precision': - return static::PHINX_TYPE_DOUBLE; - case 'bytea': - return static::PHINX_TYPE_BINARY; - case 'interval': - return static::PHINX_TYPE_INTERVAL; - case 'time': - case 'timetz': - case 'time with time zone': - case 'time without time zone': - return static::PHINX_TYPE_TIME; - case 'date': - return static::PHINX_TYPE_DATE; - case 'timestamp': - case 'timestamptz': - case 'timestamp with time zone': - case 'timestamp without time zone': - return static::PHINX_TYPE_DATETIME; - case 'bool': - case 'boolean': - return static::PHINX_TYPE_BOOLEAN; - case 'uuid': - return static::PHINX_TYPE_UUID; - case 'cidr': - return static::PHINX_TYPE_CIDR; - case 'inet': - return static::PHINX_TYPE_INET; - case 'macaddr': - return static::PHINX_TYPE_MACADDR; - default: - throw new UnsupportedColumnTypeException( - 'Column type `' . $sqlType . '` is not supported by Postgresql.', - ); + $constraintName = $checkConstraint->getName(); + if ($constraintName === null) { + // Auto-generate constraint name if not provided + $parts = $this->getSchemaName($table->getName()); + $constraintName = $parts['table'] . '_chk_' . substr(md5($checkConstraint->getExpression()), 0, 8); } + + $alter = sprintf( + 'ADD CONSTRAINT %s CHECK (%s)', + $this->quoteColumnName($constraintName), + $checkConstraint->getExpression(), + ); + + return new AlterInstructions([$alter]); + } + + /** + * @inheritDoc + */ + protected function getDropCheckConstraintInstructions(string $tableName, string $constraintName): AlterInstructions + { + $alter = sprintf( + 'DROP CONSTRAINT %s', + $this->quoteColumnName($constraintName), + ); + + return new AlterInstructions([$alter]); } /** @@ -984,8 +835,11 @@ public function createDatabase(string $name, array $options = []): void */ public function hasDatabase(string $name): bool { - $sql = sprintf("SELECT count(*) FROM pg_database WHERE datname = '%s'", $name); - $result = $this->fetchRow($sql); + $query = $this->getSelectBuilder(); + $query->select([$query->func()->count('*')]) + ->from('pg_database') + ->where(['datname' => $name]); + $result = $query->execute()->fetch('assoc'); if (!$result) { return false; } @@ -1039,9 +893,8 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin $parts = $this->getSchemaName($tableName); $columnNames = (array)$index->getColumns(); - if (is_string($index->getName())) { - $indexName = $index->getName(); - } else { + $indexName = $index->getName(); + if ($indexName === null || strlen($indexName) === 0) { $indexName = sprintf('%s_%s', $parts['table'], implode('_', $columnNames)); } @@ -1099,7 +952,7 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $ta $refColumnList = implode(', ', array_map($this->quoteColumnName(...), $foreignKey->getReferencedColumns())); $def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName) . ' FOREIGN KEY (' . $columnList . ')' . - ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()->getName()) . ' (' . $refColumnList . ')'; + ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()) . ' (' . $refColumnList . ')'; if ($foreignKey->getOnDelete()) { $def .= " ON DELETE {$foreignKey->getOnDelete()}"; } @@ -1118,7 +971,6 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $ta */ public function createSchemaTable(): void { - // Create the public/custom schema if it doesn't already exist if ($this->hasSchema($this->getGlobalSchemaName()) === false) { $this->createSchema($this->getGlobalSchemaName()); } @@ -1169,8 +1021,12 @@ public function createSchema(string $schemaName = 'public'): void */ public function hasSchema(string $schemaName): bool { - $sql = 'SELECT count(*) FROM pg_namespace WHERE nspname = ?'; - $result = $this->query($sql, [$schemaName])->fetch('assoc'); + $query = $this->getSelectBuilder(); + $query->select([$query->func()->count('*')]) + ->from('pg_namespace') + ->where(['nspname' => $schemaName]); + + $result = $query->execute()->fetch('assoc'); if (!$result) { return false; } @@ -1215,10 +1071,14 @@ public function dropAllSchemas(): void */ public function getAllSchemas(): array { - $sql = "SELECT schema_name - FROM information_schema.schemata - WHERE schema_name <> 'information_schema' AND schema_name !~ '^pg_'"; - $items = $this->fetchAll($sql); + $query = $this->getSelectBuilder(); + $query->select(['schema_name']) + ->from('information_schema.schemata') + ->where([ + ['schema_name !=' => 'information_schema'], + ['schema_name !~' => '^pg_'], + ]); + $items = $query->execute()->fetchAll('assoc'); $schemaNames = []; foreach ($items as $item) { $schemaNames[] = $item['schema_name']; @@ -1318,8 +1178,13 @@ public function setSearchPath(): void /** * @inheritDoc */ - public function insert(Table $table, array $row): void - { + public function insert( + TableMetadata $table, + array $row, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): void { $sql = sprintf( 'INSERT INTO %s ', $this->quoteTableName($table->getName()), @@ -1338,15 +1203,17 @@ public function insert(Table $table, array $row): void $override = self::OVERRIDE_SYSTEM_VALUE . ' '; } + $conflictClause = $this->getConflictClause($mode, $updateColumns, $conflictColumns); + if ($this->isDryRunEnabled()) { - $sql .= ' ' . $override . 'VALUES (' . implode(', ', array_map($this->quoteValue(...), $row)) . ');'; + $sql .= ' ' . $override . 'VALUES (' . implode(', ', array_map($this->quoteValue(...), $row)) . ')' . $conflictClause . ';'; $this->io->out($sql); } else { $values = []; $vals = []; foreach ($row as $value) { $placeholder = '?'; - if ($value instanceof Literal || $value instanceof PhinxLiteral) { + if ($value instanceof Literal) { $placeholder = (string)$value; } $values[] = $placeholder; @@ -1354,7 +1221,7 @@ public function insert(Table $table, array $row): void $vals[] = $value; } } - $sql .= ' ' . $override . 'VALUES (' . implode(',', $values) . ')'; + $sql .= ' ' . $override . 'VALUES (' . implode(',', $values) . ')' . $conflictClause; $this->getConnection()->execute($sql, $vals); } } @@ -1362,8 +1229,13 @@ public function insert(Table $table, array $row): void /** * @inheritDoc */ - public function bulkinsert(Table $table, array $rows): void - { + public function bulkinsert( + TableMetadata $table, + array $rows, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): void { $sql = sprintf( 'INSERT INTO %s ', $this->quoteTableName($table->getName()), @@ -1379,11 +1251,13 @@ public function bulkinsert(Table $table, array $rows): void $sql .= '(' . implode(', ', array_map($this->quoteColumnName(...), $keys)) . ') ' . $override . 'VALUES '; + $conflictClause = $this->getConflictClause($mode, $updateColumns, $conflictColumns); + if ($this->isDryRunEnabled()) { $values = array_map(function ($row) { return '(' . implode(', ', array_map($this->quoteValue(...), $row)) . ')'; }, $rows); - $sql .= implode(', ', $values) . ';'; + $sql .= implode(', ', $values) . $conflictClause . ';'; $this->io->out($sql); } else { $vals = []; @@ -1392,11 +1266,11 @@ public function bulkinsert(Table $table, array $rows): void $values = []; foreach ($row as $v) { $placeholder = '?'; - if ($v instanceof Literal || $v instanceof PhinxLiteral) { + if ($v instanceof Literal) { $placeholder = (string)$v; } $values[] = $placeholder; - if ($placeholder == '?') { + if ($placeholder === '?') { if ($v instanceof DateTime) { $vals[] = $v->toDateTimeString(); } elseif ($v instanceof Date) { @@ -1411,11 +1285,273 @@ public function bulkinsert(Table $table, array $rows): void $query = '(' . implode(', ', $values) . ')'; $queries[] = $query; } - $sql .= implode(',', $queries); + $sql .= implode(',', $queries) . $conflictClause; $this->getConnection()->execute($sql, $vals); } } + /** + * Get the ON CONFLICT clause based on insert mode. + * + * PostgreSQL requires explicit conflict columns to determine which unique constraint + * should trigger the update. Unlike MySQL's ON DUPLICATE KEY UPDATE which applies + * to all unique constraints, PostgreSQL's ON CONFLICT clause must specify the columns. + * + * @param \Migrations\Db\InsertMode|null $mode Insert mode + * @param array|null $updateColumns Columns to update on upsert conflict + * @param array|null $conflictColumns Columns that define uniqueness for upsert (required for PostgreSQL) + * @return string + * @throws \RuntimeException When using UPSERT mode without conflictColumns + */ + protected function getConflictClause( + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): string { + if ($mode === InsertMode::IGNORE) { + return ' ON CONFLICT DO NOTHING'; + } + + if ($mode === InsertMode::UPSERT) { + if ($conflictColumns === null || $conflictColumns === []) { + throw new RuntimeException( + 'PostgreSQL requires the $conflictColumns parameter for insertOrUpdate(). ' . + 'Specify the columns that have a unique constraint to determine conflict resolution.', + ); + } + $quotedConflictColumns = array_map($this->quoteColumnName(...), $conflictColumns); + $updates = []; + foreach ($updateColumns as $column) { + $quotedColumn = $this->quoteColumnName($column); + $updates[] = $quotedColumn . ' = EXCLUDED.' . $quotedColumn; + } + + return ' ON CONFLICT (' . implode(', ', $quotedConflictColumns) . ') DO UPDATE SET ' . implode(', ', $updates); + } + + return ''; + } + + /** + * Gets the PostgreSQL Partition Definition SQL for CREATE TABLE. + * + * @param \Migrations\Db\Table\Partition $partition Partition configuration + * @return string + */ + protected function getPartitionSqlDefinition(Partition $partition): string + { + $type = $partition->getType(); + $columns = $partition->getColumns(); + + if ($type === Partition::TYPE_KEY) { + throw new RuntimeException('KEY partitioning is not supported in PostgreSQL'); + } + + // Build column list or expression + if ($columns instanceof Literal) { + $columnsSql = (string)$columns; + } else { + $columnsSql = implode(', ', array_map(fn($col) => $this->quoteColumnName($col), $columns)); + } + + return sprintf('PARTITION BY %s (%s)', $type, $columnsSql); + } + + /** + * Gets the SQL to create a partition table in PostgreSQL. + * + * @param string $tableName The parent table name + * @param \Migrations\Db\Table\Partition $partition The partition configuration + * @param \Migrations\Db\Table\PartitionDefinition $definition The partition definition + * @return string + */ + protected function getPartitionTableSql(string $tableName, Partition $partition, PartitionDefinition $definition): string + { + $partitionTableName = $definition->getTable() ?? ($tableName . '_' . $definition->getName()); + $type = $partition->getType(); + $value = $definition->getValue(); + + $sql = sprintf( + 'CREATE TABLE %s PARTITION OF %s', + $this->quoteTableName($partitionTableName), + $this->quoteTableName($tableName), + ); + + if ($type === Partition::TYPE_RANGE) { + $sql .= $this->getRangePartitionBounds($definition); + } elseif ($type === Partition::TYPE_LIST) { + $sql .= ' FOR VALUES IN ('; + if (is_array($value)) { + $sql .= implode(', ', array_map(fn($v) => $this->quotePartitionValue($v), $value)); + } else { + $sql .= $this->quotePartitionValue($value); + } + $sql .= ')'; + } elseif ($type === Partition::TYPE_HASH) { + $count = $partition->getCount() ?? count($partition->getDefinitions()); + $index = array_search($definition, $partition->getDefinitions(), true); + $sql .= sprintf(' FOR VALUES WITH (MODULUS %d, REMAINDER %d)', $count, $index); + } + + if ($definition->getTablespace()) { + $sql .= ' TABLESPACE ' . $this->quoteColumnName($definition->getTablespace()); + } + + return $sql; + } + + /** + * Get the RANGE partition bounds for PostgreSQL. + * + * @param \Migrations\Db\Table\PartitionDefinition $definition The partition definition + * @return string + */ + protected function getRangePartitionBounds(PartitionDefinition $definition): string + { + $value = $definition->getValue(); + + // For RANGE, PostgreSQL uses FROM (value) TO (value) syntax + // When MAXVALUE is used, we use MAXVALUE keyword + if ($value === 'MAXVALUE') { + return ' FOR VALUES FROM (MAXVALUE) TO (MAXVALUE)'; + } + + // Simple case: single value means upper bound, assume MINVALUE as lower + if (!is_array($value) || !isset($value['from'])) { + $upperBound = $this->quotePartitionValue($value); + + return sprintf(' FOR VALUES FROM (MINVALUE) TO (%s)', $upperBound); + } + + // Explicit from/to + $from = $value['from']; + $to = $value['to'] ?? 'MAXVALUE'; + + $fromSql = $from === 'MINVALUE' ? 'MINVALUE' : $this->quotePartitionValue($from); + $toSql = $to === 'MAXVALUE' ? 'MAXVALUE' : $this->quotePartitionValue($to); + + return sprintf(' FOR VALUES FROM (%s) TO (%s)', $fromSql, $toSql); + } + + /** + * Quote a partition boundary value. + * + * @param mixed $value The value to quote + * @return string + */ + protected function quotePartitionValue(mixed $value): string + { + if ($value === null) { + return 'NULL'; + } + if ($value === 'MINVALUE' || $value === 'MAXVALUE') { + return $value; + } + if (is_int($value) || is_float($value)) { + return (string)$value; + } + + return $this->quoteString((string)$value); + } + + /** + * Get instructions for adding multiple partitions to an existing table. + * + * @param \Migrations\Db\Table\TableMetadata $table The table + * @param array<\Migrations\Db\Table\PartitionDefinition> $partitions The partitions to add + * @return \Migrations\Db\AlterInstructions + */ + protected function getAddPartitionsInstructions(TableMetadata $table, array $partitions): AlterInstructions + { + $instructions = new AlterInstructions(); + foreach ($partitions as $partition) { + $instructions->merge($this->getAddPartitionSql($table, $partition)); + } + + return $instructions; + } + + /** + * Get instructions for adding a single partition to an existing table. + * + * @param \Migrations\Db\Table\TableMetadata $table The table + * @param \Migrations\Db\Table\PartitionDefinition $partition The partition to add + * @return \Migrations\Db\AlterInstructions + */ + private function getAddPartitionSql(TableMetadata $table, PartitionDefinition $partition): AlterInstructions + { + // PostgreSQL requires creating partition tables using CREATE TABLE ... PARTITION OF + // This is more complex as we need the partition type info + // For now, we'll create a basic RANGE partition + $partitionTableName = $partition->getTable() ?? ($table->getName() . '_' . $partition->getName()); + $value = $partition->getValue(); + + $sql = sprintf( + 'CREATE TABLE %s PARTITION OF %s', + $this->quoteTableName($partitionTableName), + $this->quoteTableName($table->getName()), + ); + + // Detect type based on value format + if (is_array($value) && isset($value['from'])) { + // Explicit RANGE + $from = $value['from'] === 'MINVALUE' ? 'MINVALUE' : $this->quotePartitionValue($value['from']); + $to = $value['to'] === 'MAXVALUE' ? 'MAXVALUE' : $this->quotePartitionValue($value['to']); + $sql .= sprintf(' FOR VALUES FROM (%s) TO (%s)', $from, $to); + } elseif (is_array($value)) { + // LIST partition + $sql .= ' FOR VALUES IN ('; + $sql .= implode(', ', array_map(fn($v) => $this->quotePartitionValue($v), $value)); + $sql .= ')'; + } else { + // Simple RANGE (upper bound only) + $sql .= sprintf(' FOR VALUES FROM (MINVALUE) TO (%s)', $this->quotePartitionValue($value)); + } + + if ($partition->getTablespace()) { + $sql .= ' TABLESPACE ' . $this->quoteColumnName($partition->getTablespace()); + } + + return new AlterInstructions([], [$sql]); + } + + /** + * Get instructions for dropping multiple partitions from an existing table. + * + * @param string $tableName The table name + * @param array $partitionNames The partition names to drop + * @return \Migrations\Db\AlterInstructions + */ + protected function getDropPartitionsInstructions(string $tableName, array $partitionNames): AlterInstructions + { + $instructions = new AlterInstructions(); + foreach ($partitionNames as $partitionName) { + $instructions->merge($this->getDropPartitionSql($tableName, $partitionName)); + } + + return $instructions; + } + + /** + * Get instructions for dropping a single partition from an existing table. + * + * @param string $tableName The table name + * @param string $partitionName The partition name to drop + * @return \Migrations\Db\AlterInstructions + */ + private function getDropPartitionSql(string $tableName, string $partitionName): AlterInstructions + { + // In PostgreSQL, partitions are tables, so we drop the partition table + // The partition name is typically the table_partitionname + $partitionTableName = $tableName . '_' . $partitionName; + + // Use DETACH first (to preserve data) then DROP + // For a complete drop without preserving data: + $sql = sprintf('DROP TABLE IF EXISTS %s', $this->quoteTableName($partitionTableName)); + + return new AlterInstructions([], [$sql]); + } + /** * Get the adapter type name * diff --git a/src/Db/Adapter/RecordingAdapter.php b/src/Db/Adapter/RecordingAdapter.php index 3e7841b23..aab4b41b3 100644 --- a/src/Db/Adapter/RecordingAdapter.php +++ b/src/Db/Adapter/RecordingAdapter.php @@ -20,7 +20,7 @@ use Migrations\Db\Action\RenameTable; use Migrations\Db\Plan\Intent; use Migrations\Db\Plan\Plan; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; use Migrations\Migration\IrreversibleMigrationException; /** @@ -46,7 +46,7 @@ public function getAdapterType(): string /** * @inheritDoc */ - public function createTable(Table $table, array $columns = [], array $indexes = []): void + public function createTable(TableMetadata $table, array $columns = [], array $indexes = []): void { $this->commands[] = new CreateTable($table); } @@ -54,7 +54,7 @@ public function createTable(Table $table, array $columns = [], array $indexes = /** * @inheritDoc */ - public function executeActions(Table $table, array $actions): void + public function executeActions(TableMetadata $table, array $actions): void { $this->commands = array_merge($this->commands, $actions); } @@ -78,7 +78,7 @@ public function getInvertedCommands(): Intent case $command instanceof RenameTable: /** @var \Migrations\Db\Action\RenameTable $command */ - $inverted->addAction(new RenameTable(new Table($command->getNewName()), $command->getTable()->getName())); + $inverted->addAction(new RenameTable(new TableMetadata($command->getNewName()), $command->getTable()->getName())); break; case $command instanceof AddColumn: diff --git a/src/Db/Adapter/SqliteAdapter.php b/src/Db/Adapter/SqliteAdapter.php index 3ea1c5156..9145e0cb8 100644 --- a/src/Db/Adapter/SqliteAdapter.php +++ b/src/Db/Adapter/SqliteAdapter.php @@ -10,14 +10,17 @@ use BadMethodCallException; use Cake\Database\Schema\TableSchema; +use Cake\Database\Schema\TableSchemaInterface; use InvalidArgumentException; use Migrations\Db\AlterInstructions; use Migrations\Db\Expression; +use Migrations\Db\InsertMode; use Migrations\Db\Literal; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; use PDOException; use RuntimeException; use const FILTER_VALIDATE_BOOLEAN; @@ -30,90 +33,30 @@ class SqliteAdapter extends AbstractAdapter public const MEMORY = ':memory:'; /** - * List of supported Phinx column types with their SQL equivalents + * List of supported column types with their SQL equivalents * some types have an affinity appended to ensure they do not receive NUMERIC affinity * * @var string[] */ protected static array $supportedColumnTypes = [ - self::PHINX_TYPE_BIG_INTEGER => 'biginteger', - self::PHINX_TYPE_BINARY => 'binary_blob', - self::PHINX_TYPE_BINARYUUID => 'uuid_blob', - self::PHINX_TYPE_BLOB => 'blob', - self::PHINX_TYPE_BOOLEAN => 'boolean_integer', - self::PHINX_TYPE_CHAR => 'char', - self::PHINX_TYPE_DATE => 'date_text', - self::PHINX_TYPE_DATETIME => 'datetime_text', - self::PHINX_TYPE_DECIMAL => 'decimal', - self::PHINX_TYPE_DOUBLE => 'double', - self::PHINX_TYPE_FLOAT => 'float', - self::PHINX_TYPE_INTEGER => 'integer', - self::PHINX_TYPE_JSON => 'json_text', - self::PHINX_TYPE_JSONB => 'jsonb_text', - self::PHINX_TYPE_SMALL_INTEGER => 'smallinteger', - self::PHINX_TYPE_STRING => 'varchar', - self::PHINX_TYPE_TEXT => 'text', - self::PHINX_TYPE_TIME => 'time_text', - self::PHINX_TYPE_TIMESTAMP => 'timestamp_text', - self::PHINX_TYPE_TINY_INTEGER => 'tinyinteger', - self::PHINX_TYPE_UUID => 'uuid_text', - self::PHINX_TYPE_VARBINARY => 'varbinary_blob', - ]; - - /** - * List of aliases of supported column types - * - * @var string[] - */ - protected static array $supportedColumnTypeAliases = [ - 'varchar' => self::PHINX_TYPE_STRING, - 'tinyint' => self::PHINX_TYPE_TINY_INTEGER, - 'tinyinteger' => self::PHINX_TYPE_TINY_INTEGER, - 'smallint' => self::PHINX_TYPE_SMALL_INTEGER, - 'int' => self::PHINX_TYPE_INTEGER, - 'mediumint' => self::PHINX_TYPE_INTEGER, - 'mediuminteger' => self::PHINX_TYPE_INTEGER, - 'bigint' => self::PHINX_TYPE_BIG_INTEGER, - 'tinytext' => self::PHINX_TYPE_TEXT, - 'mediumtext' => self::PHINX_TYPE_TEXT, - 'longtext' => self::PHINX_TYPE_TEXT, - 'tinyblob' => self::PHINX_TYPE_BLOB, - 'mediumblob' => self::PHINX_TYPE_BLOB, - 'longblob' => self::PHINX_TYPE_BLOB, - 'real' => self::PHINX_TYPE_FLOAT, - ]; - - /** - * List of known but unsupported Phinx column types - * - * @var string[] - */ - protected static array $unsupportedColumnTypes = [ - self::PHINX_TYPE_BIT, - self::PHINX_TYPE_CIDR, - self::PHINX_TYPE_ENUM, - self::PHINX_TYPE_FILESTREAM, - self::PHINX_TYPE_GEOMETRY, - self::PHINX_TYPE_INET, - self::PHINX_TYPE_INTERVAL, - self::PHINX_TYPE_LINESTRING, - self::PHINX_TYPE_MACADDR, - self::PHINX_TYPE_POINT, - self::PHINX_TYPE_POLYGON, - self::PHINX_TYPE_SET, - ]; - - /** - * @var string[] - */ - protected array $definitionsWithLimits = [ - 'CHAR', - 'CHARACTER', - 'VARCHAR', - 'VARYING CHARACTER', - 'NCHAR', - 'NATIVE CHARACTER', - 'NVARCHAR', + self::TYPE_BIGINTEGER => 'biginteger', + self::TYPE_BINARY => 'binary_blob', + self::TYPE_BINARY_UUID => 'uuid_blob', + self::TYPE_BOOLEAN => 'boolean_integer', + self::TYPE_CHAR => 'char', + self::TYPE_DATE => 'date_text', + self::TYPE_DATETIME => 'datetime_text', + self::TYPE_DECIMAL => 'decimal', + self::TYPE_FLOAT => 'float', + self::TYPE_INTEGER => 'integer', + self::TYPE_JSON => 'json_text', + self::TYPE_SMALLINTEGER => 'smallinteger', + self::TYPE_STRING => 'varchar', + self::TYPE_TEXT => 'text', + self::TYPE_TIME => 'time_text', + self::TYPE_TIMESTAMP => 'timestamp_text', + self::TYPE_TINYINTEGER => 'tinyinteger', + self::TYPE_UUID => 'uuid_text', ]; /** @@ -286,13 +229,20 @@ protected function resolveTable(string $tableName): array */ public function hasTable(string $tableName): bool { - return $this->hasCreatedTable($tableName) || $this->resolveTable($tableName)['exists']; + // Only use the cache in dry-run mode where tables aren't actually created. + // In normal mode, always check the database to handle cases where tables + // are dropped via execute() which doesn't update the cache. + if ($this->isDryRunEnabled() && $this->hasCreatedTable($tableName)) { + return true; + } + + return $this->resolveTable($tableName)['exists']; } /** * @inheritDoc */ - public function createTable(Table $table, array $columns = [], array $indexes = []): void + public function createTable(TableMetadata $table, array $columns = [], array $indexes = []): void { // Add the default primary key $options = $table->getOptions(); @@ -365,7 +315,7 @@ public function createTable(Table $table, array $columns = [], array $indexes = * * @throws \InvalidArgumentException */ - protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): AlterInstructions + protected function getChangePrimaryKeyInstructions(TableMetadata $table, $newColumns): AlterInstructions { $instructions = new AlterInstructions(); @@ -402,7 +352,7 @@ protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): A * * @throws \BadMethodCallException */ - protected function getChangeCommentInstructions(Table $table, $newComment): AlterInstructions + protected function getChangeCommentInstructions(TableMetadata $table, $newComment): AlterInstructions { throw new BadMethodCallException('SQLite does not have table comments'); } @@ -461,7 +411,7 @@ public function truncateTable(string $tableName): void * a string value, a string representing an expression, or some other scalar * * @param mixed $default The default-value expression to interpret - * @param string $columnType The Phinx type of the column + * @param string $columnType The type of the column * @return mixed */ protected function parseDefaultValue(mixed $default, string $columnType): mixed @@ -494,16 +444,19 @@ protected function parseDefaultValue(mixed $default, string $columnType): mixed $defaultBare = rtrim(ltrim($defaultClean, $trimChars . '('), $trimChars . ')'); // match the string against one of several patterns - if ($columnType === 'text' || $columnType === 'string') { + if ($columnType === TableSchemaInterface::TYPE_TEXT || $columnType === TableschemaInterface::TYPE_STRING) { // string literal return Literal::from($default); + } elseif ($columnType === TableSchemaInterface::TYPE_BOOLEAN) { + // boolean literal + return (int)filter_var($defaultClean, FILTER_VALIDATE_BOOLEAN); } elseif (preg_match('/^CURRENT_(?:DATE|TIME|TIMESTAMP)$/i', $default)) { // magic date or time return strtoupper($default); } elseif (preg_match('/^[+-]?\d+$/i', $default)) { $int = (int)$default; // integer literal - if ($columnType === self::PHINX_TYPE_BOOLEAN && ($int === 0 || $int === 1)) { + if ($columnType === self::TYPE_BOOLEAN && ($int === 0 || $int === 1)) { return (bool)$int; } else { return $int; @@ -517,9 +470,6 @@ protected function parseDefaultValue(mixed $default, string $columnType): mixed } elseif (preg_match('/^null$/i', $defaultBare)) { // null literal return null; - } elseif (preg_match('/^true|false$/i', $defaultBare)) { - // boolean literal - return filter_var($defaultClean, FILTER_VALIDATE_BOOLEAN); } else { // any other expression: return the expression with parentheses, but without comments return Expression::from($default); @@ -604,21 +554,7 @@ public function getColumns(string $tableName): array /** * @inheritDoc */ - public function hasColumn(string $tableName, string $columnName): bool - { - foreach ($this->getColumnData($tableName) as $column) { - if (strcasecmp($column['name'], $columnName) === 0) { - return true; - } - } - - return false; - } - - /** - * @inheritDoc - */ - protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + protected function getAddColumnInstructions(TableMetadata $table, Column $column): AlterInstructions { $tableName = $table->getName(); @@ -741,17 +677,15 @@ protected function bufferIndicesAndTriggers(AlterInstructions $instructions, str $state['indices'] = []; $state['triggers'] = []; - $params = [$tableName]; - $rows = $this->query( - "SELECT * - FROM sqlite_master - WHERE - (\"type\" = 'index' OR \"type\" = 'trigger') - AND tbl_name = ? - AND sql IS NOT NULL - ", - $params, - )->fetchAll('assoc'); + $query = $this->getSelectBuilder() + ->select('*') + ->from('sqlite_master') + ->where([ + 'type IN' => ['index', 'trigger'], + 'tbl_name' => $tableName, + 'sql IS NOT' => null, + ]); + $rows = $query->execute()->fetchAll('assoc'); $indexes = $this->getIndexes($tableName); $indexMap = []; @@ -1273,32 +1207,7 @@ protected function resolveIndex(string $tableName, string|array $columns): array /** * @inheritDoc */ - public function hasIndex(string $tableName, string|array $columns): bool - { - return (bool)$this->resolveIndex($tableName, $columns); - } - - /** - * @inheritDoc - */ - public function hasIndexByName(string $tableName, string $indexName): bool - { - $indexName = strtolower($indexName); - $indexes = $this->getIndexes($tableName); - - foreach ($indexes as $index) { - if ($indexName === strtolower($index['name'])) { - return true; - } - } - - return false; - } - - /** - * @inheritDoc - */ - protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + protected function getAddIndexInstructions(TableMetadata $table, Index $index): AlterInstructions { $indexColumnArray = []; foreach ((array)$index->getColumns() as $column) { @@ -1414,25 +1323,6 @@ protected function getPrimaryKey(string $tableName): array return []; } - /** - * @inheritDoc - */ - public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool - { - $columns = array_map('mb_strtolower', (array)$columns); - - foreach ($this->getForeignKeys($tableName) as $key) { - if ($constraint !== null && $key['name'] == $constraint) { - return true; - } - if (array_map('mb_strtolower', $key['columns']) === $columns) { - return true; - } - } - - return false; - } - /** * Get an array of foreign keys from a particular table. * @@ -1448,11 +1338,11 @@ protected function getForeignKeys(string $tableName): array } /** - * @param \Migrations\Db\Table\Table $table The Table + * @param \Migrations\Db\Table\TableMetadata $table The Table * @param string $column Column Name * @return \Migrations\Db\AlterInstructions */ - protected function getAddPrimaryKeyInstructions(Table $table, string $column): AlterInstructions + protected function getAddPrimaryKeyInstructions(TableMetadata $table, string $column): AlterInstructions { $instructions = $this->beginAlterByCopyTable($table->getName()); @@ -1460,13 +1350,16 @@ protected function getAddPrimaryKeyInstructions(Table $table, string $column): A $instructions->addPostStep(function ($state) use ($column) { $quotedColumn = preg_quote($column); $columnPattern = "`{$quotedColumn}`|\"{$quotedColumn}\"|\[{$quotedColumn}\]"; - $matchPattern = "/($columnPattern)\s+(\w+(\(\d+\))?)(\s+(NOT )?NULL)?/"; + $matchPattern = "/($columnPattern)\s+(\w+(\(\d+\))?)(\s+(NOT )?NULL)?(\s+(?:PRIMARY KEY\s+)?AUTOINCREMENT)?/i"; $sql = $state['createSQL']; if (preg_match($matchPattern, $state['createSQL'], $matches)) { if (isset($matches[2])) { - if ($matches[2] === 'INTEGER') { + $hasAutoIncrement = isset($matches[6]) && stripos($matches[6], 'AUTOINCREMENT') !== false; + + if ($matches[2] === 'INTEGER' && $hasAutoIncrement) { + // Only add AUTOINCREMENT if the column already had it $replace = '$1 INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT'; } else { $replace = '$1 $2 NOT NULL PRIMARY KEY'; @@ -1493,11 +1386,11 @@ protected function getAddPrimaryKeyInstructions(Table $table, string $column): A } /** - * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\TableMetadata $table Table * @param string $column Column Name * @return \Migrations\Db\AlterInstructions */ - protected function getDropPrimaryKeyInstructions(Table $table, string $column): AlterInstructions + protected function getDropPrimaryKeyInstructions(TableMetadata $table, string $column): AlterInstructions { $tableName = $table->getName(); $instructions = $this->beginAlterByCopyTable($tableName); @@ -1525,7 +1418,7 @@ protected function getDropPrimaryKeyInstructions(Table $table, string $column): /** * @inheritDoc */ - protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + protected function getAddForeignKeyInstructions(TableMetadata $table, ForeignKey $foreignKey): AlterInstructions { $instructions = $this->beginAlterByCopyTable($table->getName()); @@ -1628,78 +1521,83 @@ protected function getDropForeignKeyByColumnsInstructions(string $tableName, arr } /** - * {@inheritDoc} + * Get an array of check constraints from a particular table. * - * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException + * @param string $tableName Table name + * @return array */ - public function getSqlType(Literal|string $type, ?int $limit = null): array + protected function getCheckConstraints(string $tableName): array { - if ($type instanceof Literal) { - $name = $type; - } else { - $typeLC = strtolower($type); + $dialect = $this->getSchemaDialect(); - if (isset(static::$supportedColumnTypes[$typeLC])) { - $name = static::$supportedColumnTypes[$typeLC]; - } elseif (in_array($typeLC, static::$unsupportedColumnTypes, true)) { - throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not supported by SQLite.'); - } else { - throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not known by SQLite.'); + return $dialect->describeCheckConstraints($tableName); + } + + /** + * @inheritDoc + */ + protected function getAddCheckConstraintInstructions(TableMetadata $table, CheckConstraint $checkConstraint): AlterInstructions + { + $tableName = $table->getName(); + $instructions = $this->beginAlterByCopyTable($tableName); + + $instructions->addPostStep(function ($state) use ($checkConstraint) { + $constraintName = $checkConstraint->getName(); + if ($constraintName === null) { + // Auto-generate constraint name if not provided + $constraintName = 'chk_' . substr(md5($checkConstraint->getExpression()), 0, 8); } - } - return ['name' => $name, 'limit' => $limit]; + $checkDef = sprintf( + 'CONSTRAINT %s CHECK (%s)', + $this->quoteColumnName($constraintName), + $checkConstraint->getExpression(), + ); + + // Add the check constraint before the closing parenthesis + $sql = substr($state['createSQL'], 0, -1) . ', ' . $checkDef . ')'; + $this->execute($sql); + + return $state; + }); + + $instructions->addPostStep(function ($state) use ($tableName) { + $newState = $this->calculateNewTableColumns($tableName, false, false); + + return $newState + $state; + }); + + return $this->endAlterByCopyTable($instructions, $tableName); } /** - * Returns Phinx type by SQL type - * - * @param string|null $sqlTypeDef SQL Type definition - * @return array + * @inheritDoc */ - public function getPhinxType(?string $sqlTypeDef): array + protected function getDropCheckConstraintInstructions(string $tableName, string $constraintName): AlterInstructions { - $limit = null; - $scale = null; - if ($sqlTypeDef === null) { - // in SQLite columns can legitimately have null as a type, which is distinct from the empty string - $name = null; - } else { - if (!preg_match('/^([a-z]+)(_(?:integer|float|text|blob))?(?:\((\d+)(?:,(\d+))?\))?$/i', $sqlTypeDef, $match)) { - // doesn't match the pattern of a type we'd know about - $name = Literal::from($sqlTypeDef); - } else { - // possibly a known type - $type = $match[1]; - $typeLC = strtolower($type); - $affinity = $match[2] ?? ''; - $limit = isset($match[3]) && strlen($match[3]) ? (int)$match[3] : null; - $scale = isset($match[4]) && strlen($match[4]) ? (int)$match[4] : null; - if (in_array($typeLC, ['tinyint', 'tinyinteger'], true) && $limit === 1) { - // the type is a MySQL-style boolean - $name = static::PHINX_TYPE_BOOLEAN; - $limit = null; - } elseif (isset(static::$supportedColumnTypes[$typeLC])) { - // the type is an explicitly supported type - $name = $typeLC; - } elseif (isset(static::$supportedColumnTypeAliases[$typeLC])) { - // the type is an alias for a supported type - $name = static::$supportedColumnTypeAliases[$typeLC]; - } elseif (in_array($typeLC, static::$unsupportedColumnTypes, true)) { - // unsupported but known types are passed through lowercased, and without appended affinity - $name = Literal::from($typeLC); - } else { - // unknown types are passed through as-is - $name = Literal::from($type . $affinity); - } + $instructions = $this->beginAlterByCopyTable($tableName); + + $instructions->addPostStep(function ($state) use ($constraintName) { + // Remove the check constraint from the CREATE TABLE statement + // Match CONSTRAINT name CHECK (expression) or just CHECK (expression) + $quotedName = $this->possiblyQuotedIdentifierRegex($constraintName, false); + $pattern = "/,?\s*CONSTRAINT\s+{$quotedName}\s+CHECK\s*\([^)]+(?:\([^)]*\)[^)]*)*\)/is"; + + $sql = preg_replace($pattern, '', (string)$state['createSQL'], 1); + if ($sql) { + $this->execute($sql); } - } - return [ - 'name' => $name, - 'limit' => $limit, - 'scale' => $scale, - ]; + return $state; + }); + + $instructions->addPostStep(function ($state) use ($tableName) { + $newState = $this->calculateNewTableColumns($tableName, false, false); + + return $newState + $state; + }); + + return $this->endAlterByCopyTable($instructions, $tableName); } /** @@ -1736,11 +1634,11 @@ public function dropDatabase(string $name): void /** * Gets the SQLite Index Definition for an Index object. * - * @param \Migrations\Db\Table\Table $table Table + * @param \Migrations\Db\Table\TableMetadata $table Table * @param \Migrations\Db\Table\Index $index Index * @return string */ - protected function getIndexSqlDefinition(Table $table, Index $index): string + protected function getIndexSqlDefinition(TableMetadata $table, Index $index): string { if ($index->getType() === Index::UNIQUE) { $def = 'UNIQUE INDEX'; @@ -1748,7 +1646,7 @@ protected function getIndexSqlDefinition(Table $table, Index $index): string $def = 'INDEX'; } $indexName = $index->getName(); - if (!is_string($indexName)) { + if ($indexName == '') { $indexName = $table->getName() . '_'; foreach ((array)$index->getColumns() as $column) { $indexName .= $column . '_'; @@ -1789,7 +1687,7 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string foreach ($foreignKey->getReferencedColumns() as $column) { $refColumnNames[] = $this->quoteColumnName($column); } - $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()->getName()) . ' (' . implode(',', $refColumnNames) . ')'; + $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()) . ' (' . implode(',', $refColumnNames) . ')'; if ($foreignKey->getOnDelete()) { $def .= ' ON DELETE ' . $foreignKey->getOnDelete(); } @@ -1799,4 +1697,52 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string return $def; } + + /** + * @inheritDoc + */ + protected function getInsertPrefix(?InsertMode $mode = null): string + { + if ($mode === InsertMode::IGNORE) { + return 'INSERT OR IGNORE'; + } + + return 'INSERT'; + } + + /** + * Get the upsert clause for SQLite (ON CONFLICT ... DO UPDATE SET). + * + * SQLite requires explicit conflict columns to determine which unique constraint + * should trigger the update. Unlike MySQL's ON DUPLICATE KEY UPDATE which applies + * to all unique constraints, SQLite's ON CONFLICT clause must specify the columns. + * + * @param \Migrations\Db\InsertMode|null $mode Insert mode + * @param array|null $updateColumns Columns to update on conflict + * @param array|null $conflictColumns Columns that define uniqueness for upsert (required for SQLite) + * @return string + * @throws \RuntimeException When using UPSERT mode without conflictColumns + */ + protected function getUpsertClause(?InsertMode $mode, ?array $updateColumns, ?array $conflictColumns = null): string + { + if ($mode !== InsertMode::UPSERT || $updateColumns === null) { + return ''; + } + + if ($conflictColumns === null || $conflictColumns === []) { + throw new RuntimeException( + 'SQLite requires the $conflictColumns parameter for insertOrUpdate(). ' . + 'Specify the columns that have a unique constraint to determine conflict resolution.', + ); + } + + $quotedConflictColumns = array_map($this->quoteColumnName(...), $conflictColumns); + $updates = []; + foreach ($updateColumns as $column) { + $quotedColumn = $this->quoteColumnName($column); + $updates[] = $quotedColumn . ' = excluded.' . $quotedColumn; + } + + return ' ON CONFLICT (' . implode(', ', $quotedConflictColumns) . ') DO UPDATE SET ' . implode(', ', $updates); + } } diff --git a/src/Db/Adapter/SqlserverAdapter.php b/src/Db/Adapter/SqlserverAdapter.php index ec7bf4865..e804d5908 100644 --- a/src/Db/Adapter/SqlserverAdapter.php +++ b/src/Db/Adapter/SqlserverAdapter.php @@ -14,14 +14,14 @@ use Cake\I18n\DateTime; use InvalidArgumentException; use Migrations\Db\AlterInstructions; +use Migrations\Db\InsertMode; use Migrations\Db\Literal; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; -use Migrations\Db\Table\Table; -use Migrations\Db\Table\Table as TableMetadata; +use Migrations\Db\Table\TableMetadata; use Migrations\MigrationInterface; -use Phinx\Util\Literal as PhinxLiteral; /** * Migrations SqlServer Adapter. @@ -32,9 +32,8 @@ class SqlserverAdapter extends AbstractAdapter * @var string[] */ protected static array $specificColumnTypes = [ - self::PHINX_TYPE_FILESTREAM, - self::PHINX_TYPE_BINARYUUID, - self::PHINX_TYPE_NATIVEUUID, + self::TYPE_BINARY_UUID, + self::TYPE_NATIVE_UUID, ]; /** @@ -42,16 +41,6 @@ class SqlserverAdapter extends AbstractAdapter */ protected string $schema = 'dbo'; - /** - * @var bool[] - */ - protected array $signedColumnTypes = [ - self::PHINX_TYPE_INTEGER => true, - self::PHINX_TYPE_BIG_INTEGER => true, - self::PHINX_TYPE_FLOAT => true, - self::PHINX_TYPE_DECIMAL => true, - ]; - /** * Quotes a schema name for use in a query. * @@ -78,24 +67,23 @@ public function quoteTableName(string $tableName): string */ public function hasTable(string $tableName): bool { - if ($this->hasCreatedTable($tableName)) { + // Only use the cache in dry-run mode where tables aren't actually created. + // In normal mode, always check the database to handle cases where tables + // are dropped via execute() which doesn't update the cache. + if ($this->isDryRunEnabled() && $this->hasCreatedTable($tableName)) { return true; } - $dialect = $this->getSchemaDialect(); $parts = $this->getSchemaName($tableName); - [$query, $params] = $dialect->listTablesSql(['schema' => $parts['schema']]); - - $rows = $this->query($query, $params)->fetchAll(); - $tables = array_column($rows, 0); + $dialect = $this->getSchemaDialect(); - return in_array($parts['table'], $tables, true); + return $dialect->hasTable($parts['table'], $parts['schema']); } /** * @inheritDoc */ - public function createTable(Table $table, array $columns = [], array $indexes = []): void + public function createTable(TableMetadata $table, array $columns = [], array $indexes = []): void { $options = $table->getOptions(); $parts = $this->getSchemaName($table->getName()); @@ -172,7 +160,7 @@ public function createTable(Table $table, array $columns = [], array $indexes = * * @throws \InvalidArgumentException */ - protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): AlterInstructions + protected function getChangePrimaryKeyInstructions(TableMetadata $table, $newColumns): AlterInstructions { $instructions = new AlterInstructions(); @@ -211,7 +199,7 @@ protected function getChangePrimaryKeyInstructions(Table $table, $newColumns): A * SqlServer does not implement this functionality, and so will always throw an exception if used. * @throws \BadMethodCallException */ - protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions + protected function getChangeCommentInstructions(TableMetadata $table, ?string $newComment): AlterInstructions { throw new BadMethodCallException('SqlServer does not have table comments'); } @@ -329,7 +317,7 @@ public function getColumns(string $tableName): array $column->setIdentity($columnInfo['autoIncrement']); } - $columns[$columnInfo['name']] = $column; + $columns[] = $column; } return $columns; @@ -362,22 +350,7 @@ protected function parseDefault(?string $default): int|string|null /** * @inheritDoc */ - public function hasColumn(string $tableName, string $columnName): bool - { - $parts = $this->getSchemaName($tableName); - $sql = "SELECT count(*) as [count] - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?"; - /** @var array $result */ - $result = $this->query($sql, [$parts['schema'], $parts['table'], $columnName])->fetch('assoc'); - - return $result['count'] > 0; - } - - /** - * @inheritDoc - */ - protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + protected function getAddColumnInstructions(TableMetadata $table, Column $column): AlterInstructions { $dialect = $this->getSchemaDialect(); $alter = sprintf( @@ -464,13 +437,20 @@ protected function getChangeDefault(string $tableName, Column $newColumn): Alter protected function getChangeColumnInstructions(string $tableName, string $columnName, Column $newColumn): AlterInstructions { $columns = $this->getColumns($tableName); - if (!isset($columns[$columnName])) { + $oldColumn = null; + foreach ($columns as $column) { + if ($column->getName() === $columnName) { + $oldColumn = $column; + break; + } + } + if ($oldColumn === null) { throw new InvalidArgumentException("Unknown column {$columnName} cannot be changed."); } $changeDefault = - $newColumn->getDefault() !== $columns[$columnName]->getDefault() || - $newColumn->getType() !== $columns[$columnName]->getType(); + $newColumn->getDefault() !== $oldColumn->getDefault() || + $newColumn->getType() !== $oldColumn->getType(); $instructions = new AlterInstructions(); $dialect = $this->getSchemaDialect(); @@ -599,45 +579,7 @@ public function getIndexes(string $tableName): array /** * @inheritDoc */ - public function hasIndex(string $tableName, string|array $columns): bool - { - if (is_string($columns)) { - $columns = [$columns]; // str to array - } - - $columns = array_map('strtolower', $columns); - $indexes = $this->getIndexes($tableName); - - foreach ($indexes as $index) { - $a = array_diff($columns, $index['columns']); - if (!$a) { - return true; - } - } - - return false; - } - - /** - * @inheritDoc - */ - public function hasIndexByName(string $tableName, string $indexName): bool - { - $indexes = $this->getIndexes($tableName); - - foreach ($indexes as $index) { - if ($index['name'] === $indexName) { - return true; - } - } - - return false; - } - - /** - * @inheritDoc - */ - protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + protected function getAddIndexInstructions(TableMetadata $table, Index $index): AlterInstructions { $sql = $this->getIndexSqlDefinition($index, $table->getName()); @@ -744,35 +686,6 @@ public function getPrimaryKey(string $tableName): array return $primaryKey; } - /** - * @inheritDoc - */ - public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool - { - $foreignKeys = $this->getForeignKeys($tableName); - if ($constraint) { - foreach ($foreignKeys as $key) { - if ($key['name'] === $constraint) { - return true; - } - } - - return false; - } - - if (is_string($columns)) { - $columns = [$columns]; - } - - foreach ($foreignKeys as $key) { - if ($key['columns'] === $columns) { - return true; - } - } - - return false; - } - /** * Get an array of foreign keys from a particular table. * @@ -789,7 +702,7 @@ protected function getForeignKeys(string $tableName): array /** * @inheritDoc */ - protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + protected function getAddForeignKeyInstructions(TableMetadata $table, ForeignKey $foreignKey): AlterInstructions { $instructions = new AlterInstructions(); $instructions->addPostStep(sprintf( @@ -847,122 +760,6 @@ protected function getDropForeignKeyByColumnsInstructions(string $tableName, arr return $instructions; } - /** - * {@inheritDoc} - * - * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException - */ - public function getSqlType(Literal|string $type, ?int $limit = null): array - { - $type = (string)$type; - switch ($type) { - case static::PHINX_TYPE_FLOAT: - case static::PHINX_TYPE_DECIMAL: - case static::PHINX_TYPE_DATETIME: - case static::PHINX_TYPE_TIME: - case static::PHINX_TYPE_DATE: - return ['name' => $type]; - case static::PHINX_TYPE_STRING: - return ['name' => 'nvarchar', 'limit' => 255]; - case static::PHINX_TYPE_CHAR: - return ['name' => 'nchar', 'limit' => 255]; - case static::PHINX_TYPE_TEXT: - return ['name' => 'ntext']; - case static::PHINX_TYPE_INTEGER: - return ['name' => 'int']; - case static::PHINX_TYPE_TINY_INTEGER: - return ['name' => 'tinyint']; - case static::PHINX_TYPE_SMALL_INTEGER: - return ['name' => 'smallint']; - case static::PHINX_TYPE_BIG_INTEGER: - return ['name' => 'bigint']; - case static::PHINX_TYPE_TIMESTAMP: - return ['name' => 'datetime']; - case static::PHINX_TYPE_BLOB: - case static::PHINX_TYPE_BINARY: - return ['name' => 'varbinary']; - case static::PHINX_TYPE_BOOLEAN: - return ['name' => 'bit']; - case static::PHINX_TYPE_BINARYUUID: - case static::PHINX_TYPE_UUID: - case static::PHINX_TYPE_NATIVEUUID: - return ['name' => 'uniqueidentifier']; - case static::PHINX_TYPE_FILESTREAM: - return ['name' => 'varbinary', 'limit' => 'max']; - // Geospatial database types - case static::PHINX_TYPE_GEOGRAPHY: - case static::PHINX_TYPE_POINT: - case static::PHINX_TYPE_LINESTRING: - case static::PHINX_TYPE_POLYGON: - // SQL Server stores all spatial data using a single data type. - // Specific types (point, polygon, etc) are set at insert time. - return ['name' => 'geography']; - // Geometry specific type - case static::PHINX_TYPE_GEOMETRY: - return ['name' => 'geometry']; - default: - throw new UnsupportedColumnTypeException('Column type "' . $type . '" is not supported by SqlServer.'); - } - } - - /** - * Returns Phinx type by SQL type - * - * @internal param string $sqlType SQL type - * @param string $sqlType SQL Type definition - * @throws \Migrations\Db\Adapter\UnsupportedColumnTypeException - * @return string Phinx type - */ - public function getPhinxType(string $sqlType): string - { - switch ($sqlType) { - case 'nvarchar': - case 'varchar': - return static::PHINX_TYPE_STRING; - case 'char': - case 'nchar': - return static::PHINX_TYPE_CHAR; - case 'text': - case 'ntext': - return static::PHINX_TYPE_TEXT; - case 'int': - case 'integer': - return static::PHINX_TYPE_INTEGER; - case 'decimal': - case 'numeric': - case 'money': - return static::PHINX_TYPE_DECIMAL; - case 'tinyint': - return static::PHINX_TYPE_TINY_INTEGER; - case 'smallint': - return static::PHINX_TYPE_SMALL_INTEGER; - case 'bigint': - return static::PHINX_TYPE_BIG_INTEGER; - case 'real': - case 'float': - return static::PHINX_TYPE_FLOAT; - case 'binary': - case 'image': - case 'varbinary': - return static::PHINX_TYPE_BINARY; - case 'time': - return static::PHINX_TYPE_TIME; - case 'date': - return static::PHINX_TYPE_DATE; - case 'datetime': - case 'timestamp': - return static::PHINX_TYPE_DATETIME; - case 'bit': - return static::PHINX_TYPE_BOOLEAN; - case 'uniqueidentifier': - return static::PHINX_TYPE_UUID; - case 'filestream': - return static::PHINX_TYPE_FILESTREAM; - default: - throw new UnsupportedColumnTypeException('Column type "' . $sqlType . '" is not supported by SqlServer.'); - } - } - /** * @inheritDoc */ @@ -1027,7 +824,7 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin $columnNames = (array)$index->getColumns(); $indexName = $index->getName(); - if (!is_string($indexName)) { + if ($indexName == '') { $indexName = sprintf('%s_%s', $parts['table'], implode('_', $columnNames)); } $order = $index->getOrder() ?? []; @@ -1073,7 +870,7 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $ta $def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName); $def .= ' FOREIGN KEY (' . $columnList . ')'; - $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()->getName()) . ' (' . $refColumnList . ')'; + $def .= ' REFERENCES ' . $this->quoteTableName($foreignKey->getReferencedTable()) . ' (' . $refColumnList . ')'; if ($foreignKey->getOnDelete()) { $def .= " ON DELETE {$foreignKey->getOnDelete()}"; } @@ -1210,7 +1007,7 @@ public function getColumnTypes(): array * @param string $direction Direction * @param string $startTime Start Time * @param string $endTime End Time - * @return \Phinx\Db\Adapter\AdapterInterface + * @return \Migrations\Db\Adapter\AdapterInterface */ public function migrated(MigrationInterface $migration, string $direction, string $startTime, string $endTime): AdapterInterface { @@ -1223,9 +1020,14 @@ public function migrated(MigrationInterface $migration, string $direction, strin /** * @inheritDoc */ - public function insert(TableMetadata $table, array $row): void - { - $sql = $this->generateInsertSql($table, $row); + public function insert( + TableMetadata $table, + array $row, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): void { + $sql = $this->generateInsertSql($table, $row, $mode, $updateColumns, $conflictColumns); $sql = $this->updateSQLForIdentityInsert($table->getName(), $sql); @@ -1235,7 +1037,7 @@ public function insert(TableMetadata $table, array $row): void $vals = []; foreach ($row as $value) { $placeholder = '?'; - if ($value instanceof Literal || $value instanceof PhinxLiteral) { + if ($value instanceof Literal) { $placeholder = (string)$value; } if ($placeholder === '?') { @@ -1249,9 +1051,14 @@ public function insert(TableMetadata $table, array $row): void /** * @inheritDoc */ - public function bulkinsert(TableMetadata $table, array $rows): void - { - $sql = $this->generateBulkInsertSql($table, $rows); + public function bulkinsert( + TableMetadata $table, + array $rows, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): void { + $sql = $this->generateBulkInsertSql($table, $rows, $mode, $updateColumns, $conflictColumns); $sql = $this->updateSQLForIdentityInsert($table->getName(), $sql); @@ -1262,7 +1069,7 @@ public function bulkinsert(TableMetadata $table, array $rows): void foreach ($rows as $row) { foreach ($row as $v) { $placeholder = '?'; - if ($v instanceof Literal || $v instanceof PhinxLiteral) { + if ($v instanceof Literal) { $placeholder = (string)$v; } if ($placeholder == '?') { @@ -1304,4 +1111,52 @@ private function updateSQLForIdentityInsert(string $tableName, string $sql): str return $sql; } + + /** + * @inheritDoc + * + * Note: Check constraints are not supported for SQL Server adapter. + * This method returns an empty array. Use raw SQL via execute() if you need + * check constraints on SQL Server. + */ + protected function getCheckConstraints(string $tableName): array + { + return []; + } + + /** + * @inheritDoc + * @throws \BadMethodCallException Check constraints are not supported for SQL Server. + */ + protected function getAddCheckConstraintInstructions(TableMetadata $table, CheckConstraint $checkConstraint): AlterInstructions + { + throw new BadMethodCallException( + 'Check constraints are not supported for the SQL Server adapter. ' . + 'Use $this->execute() with raw SQL to add check constraints.', + ); + } + + /** + * @inheritDoc + * @throws \BadMethodCallException Check constraints are not supported for SQL Server. + */ + protected function getDropCheckConstraintInstructions(string $tableName, string $constraintName): AlterInstructions + { + throw new BadMethodCallException( + 'Check constraints are not supported for the SQL Server adapter. ' . + 'Use $this->execute() with raw SQL to drop check constraints.', + ); + } + + /** + * @inheritDoc + */ + protected function getInsertPrefix(?InsertMode $mode = null): string + { + if ($mode === InsertMode::IGNORE) { + throw new BadMethodCallException('INSERT IGNORE is not supported for SQL Server'); + } + + return parent::getInsertPrefix($mode); + } } diff --git a/src/Db/Adapter/TimedOutputAdapter.php b/src/Db/Adapter/TimedOutputAdapter.php index 6706bd276..f5e11be62 100644 --- a/src/Db/Adapter/TimedOutputAdapter.php +++ b/src/Db/Adapter/TimedOutputAdapter.php @@ -10,10 +10,11 @@ use BadMethodCallException; use Cake\Console\ConsoleIo; +use Migrations\Db\InsertMode; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; /** * Wraps any adapter to record the time spend executing its commands @@ -44,7 +45,7 @@ public function startCommandTimer(): callable } /** - * Write a Phinx command to the output. + * Write a command to the output. * * @param string $command Command Name * @param array $args Command Args @@ -83,29 +84,39 @@ function ($value) { /** * @inheritDoc */ - public function insert(Table $table, array $row): void - { + public function insert( + TableMetadata $table, + array $row, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): void { $end = $this->startCommandTimer(); $this->writeCommand('insert', [$table->getName()]); - parent::insert($table, $row); + parent::insert($table, $row, $mode, $updateColumns, $conflictColumns); $end(); } /** * @inheritDoc */ - public function bulkinsert(Table $table, array $rows): void - { + public function bulkinsert( + TableMetadata $table, + array $rows, + ?InsertMode $mode = null, + ?array $updateColumns = null, + ?array $conflictColumns = null, + ): void { $end = $this->startCommandTimer(); $this->writeCommand('bulkinsert', [$table->getName()]); - parent::bulkinsert($table, $rows); + parent::bulkinsert($table, $rows, $mode, $updateColumns, $conflictColumns); $end(); } /** * @inheritDoc */ - public function createTable(Table $table, array $columns = [], array $indexes = []): void + public function createTable(TableMetadata $table, array $columns = [], array $indexes = []): void { $end = $this->startCommandTimer(); $this->writeCommand('createTable', [$table->getName()]); @@ -119,7 +130,7 @@ public function createTable(Table $table, array $columns = [], array $indexes = * @throws \BadMethodCallException * @return void */ - public function changePrimaryKey(Table $table, $newColumns): void + public function changePrimaryKey(TableMetadata $table, $newColumns): void { $adapter = $this->getAdapter(); if (!$adapter instanceof DirectActionInterface) { @@ -137,7 +148,7 @@ public function changePrimaryKey(Table $table, $newColumns): void * @throws \BadMethodCallException * @return void */ - public function changeComment(Table $table, ?string $newComment): void + public function changeComment(TableMetadata $table, ?string $newComment): void { $adapter = $this->getAdapter(); if (!$adapter instanceof DirectActionInterface) { @@ -202,7 +213,7 @@ public function truncateTable(string $tableName): void * @throws \BadMethodCallException * @return void */ - public function addColumn(Table $table, Column $column): void + public function addColumn(TableMetadata $table, Column $column): void { $adapter = $this->getAdapter(); if (!$adapter instanceof DirectActionInterface) { @@ -281,7 +292,7 @@ public function dropColumn(string $tableName, string $columnName): void * @throws \BadMethodCallException * @return void */ - public function addIndex(Table $table, Index $index): void + public function addIndex(TableMetadata $table, Index $index): void { $adapter = $this->getAdapter(); if (!$adapter instanceof DirectActionInterface) { @@ -335,7 +346,7 @@ public function dropIndexByName(string $tableName, string $indexName): void * @throws \BadMethodCallException * @return void */ - public function addForeignKey(Table $table, ForeignKey $foreignKey): void + public function addForeignKey(TableMetadata $table, ForeignKey $foreignKey): void { $adapter = $this->getAdapter(); if (!$adapter instanceof DirectActionInterface) { @@ -412,7 +423,7 @@ public function dropSchema(string $schemaName): void /** * @inheritDoc */ - public function executeActions(Table $table, array $actions): void + public function executeActions(TableMetadata $table, array $actions): void { $end = $this->startCommandTimer(); $this->writeCommand(sprintf('Altering table %s', $table->getName())); diff --git a/src/Db/Adapter/UnifiedMigrationsTableStorage.php b/src/Db/Adapter/UnifiedMigrationsTableStorage.php new file mode 100644 index 000000000..fab2cf686 --- /dev/null +++ b/src/Db/Adapter/UnifiedMigrationsTableStorage.php @@ -0,0 +1,255 @@ +adapter->beginTransaction(); + try { + $where = ['version IN' => $missingVersions]; + $where['plugin IS'] = $this->adapter->getOption('plugin'); + + $delete = $this->adapter->getDeleteBuilder() + ->from(self::TABLE_NAME) + ->where($where); + $delete->execute(); + $this->adapter->commitTransaction(); + } catch (Exception $e) { + $this->adapter->rollbackTransaction(); + throw $e; + } + } + + /** + * Gets all the migration versions for the current plugin context. + * + * @param array $orderBy The order by clause. + * @return \Cake\Database\Query\SelectQuery + */ + public function getVersions(array $orderBy): SelectQuery + { + $query = $this->adapter->getSelectBuilder(); + $query->select('*') + ->from(self::TABLE_NAME) + ->where(['plugin IS' => $this->plugin]) + ->orderBy($orderBy); + + return $query; + } + + /** + * Records that a migration was run in the database. + * + * @param \Migrations\MigrationInterface $migration Migration + * @param string $startTime Start time + * @param string $endTime End time + * @return void + */ + public function recordUp(MigrationInterface $migration, string $startTime, string $endTime): void + { + $query = $this->adapter->getInsertBuilder(); + $query->insert(['version', 'migration_name', 'plugin', 'start_time', 'end_time', 'breakpoint']) + ->into(self::TABLE_NAME) + ->values([ + 'version' => (string)$migration->getVersion(), + 'migration_name' => substr($migration->getName(), 0, 100), + 'plugin' => $this->plugin, + 'start_time' => $startTime, + 'end_time' => $endTime, + 'breakpoint' => 0, + ]); + $this->adapter->executeQuery($query); + } + + /** + * Removes the record of a migration having been run. + * + * @param \Migrations\MigrationInterface $migration Migration + * @return void + */ + public function recordDown(MigrationInterface $migration): void + { + $query = $this->adapter->getDeleteBuilder(); + $query->delete() + ->from(self::TABLE_NAME) + ->where([ + 'version' => (string)$migration->getVersion(), + 'plugin IS' => $this->plugin, + ]); + + $this->adapter->executeQuery($query); + } + + /** + * Toggles the breakpoint state of a migration. + * + * @param \Migrations\MigrationInterface $migration Migration + * @return void + */ + public function toggleBreakpoint(MigrationInterface $migration): void + { + $pluginCondition = $this->plugin === null + ? sprintf('%s IS NULL', $this->adapter->quoteColumnName('plugin')) + : sprintf('%s = ?', $this->adapter->quoteColumnName('plugin')); + + $params = $this->plugin === null + ? [$migration->getVersion()] + : [$migration->getVersion(), $this->plugin]; + + $this->adapter->query( + sprintf( + 'UPDATE %1$s SET %2$s = CASE %2$s WHEN true THEN false ELSE true END, %4$s = %4$s WHERE %3$s = ? AND %5$s;', + $this->adapter->quoteTableName(self::TABLE_NAME), + $this->adapter->quoteColumnName('breakpoint'), + $this->adapter->quoteColumnName('version'), + $this->adapter->quoteColumnName('start_time'), + $pluginCondition, + ), + $params, + ); + } + + /** + * Resets all breakpoints for the current plugin context. + * + * @return int The number of affected rows. + */ + public function resetAllBreakpoints(): int + { + $query = $this->adapter->getUpdateBuilder(); + $query->update(self::TABLE_NAME) + ->set([ + 'breakpoint' => 0, + 'start_time' => $query->identifier('start_time'), + ]) + ->where([ + 'breakpoint !=' => 0, + 'plugin IS' => $this->plugin, + ]); + + return $this->adapter->executeQuery($query); + } + + /** + * Marks a migration as a breakpoint or not depending on $state. + * + * @param \Migrations\MigrationInterface $migration Migration + * @param bool $state The breakpoint state to set. + * @return void + */ + public function markBreakpoint(MigrationInterface $migration, bool $state): void + { + $query = $this->adapter->getUpdateBuilder(); + $query->update(self::TABLE_NAME) + ->set([ + 'breakpoint' => (int)$state, + 'start_time' => $query->identifier('start_time'), + ]) + ->where([ + 'version' => $migration->getVersion(), + 'plugin IS' => $this->plugin, + ]); + + $this->adapter->executeQuery($query); + } + + /** + * Creates the unified migration storage table. + * + * @return void + * @throws \InvalidArgumentException When there is a problem creating the table. + */ + public function createTable(): void + { + try { + $options = [ + 'id' => true, + 'primary_key' => 'id', + ]; + + $table = new Table(self::TABLE_NAME, $options, $this->adapter); + $table->addColumn('version', 'biginteger', ['null' => false]) + ->addColumn('migration_name', 'string', ['limit' => 100, 'default' => null, 'null' => true]) + ->addColumn('plugin', 'string', ['limit' => 100, 'default' => null, 'null' => true]) + ->addColumn('start_time', 'timestamp', ['default' => null, 'null' => true]) + ->addColumn('end_time', 'timestamp', ['default' => null, 'null' => true]) + ->addColumn('breakpoint', 'boolean', ['default' => false, 'null' => false]) + ->addIndex(['version', 'plugin'], ['unique' => true, 'name' => 'version_plugin_unique']) + ->save(); + } catch (Exception $exception) { + throw new InvalidArgumentException( + 'There was a problem creating the migrations table: ' . $exception->getMessage(), + (int)$exception->getCode(), + $exception, + ); + } + } + + /** + * Upgrades the migration storage table if needed. + * + * Since the unified cake_migrations table is new in v5.0 and always created + * with all required columns, this is currently a no-op. Future schema changes + * would add upgrade logic here. + * + * @return void + */ + public function upgradeTable(): void + { + // No-op for new installations. Schema upgrades can be added here + // if the table structure changes in future versions. + } +} diff --git a/src/Db/Adapter/UnsupportedColumnTypeException.php b/src/Db/Adapter/UnsupportedColumnTypeException.php index a3b28311f..2277b04d9 100644 --- a/src/Db/Adapter/UnsupportedColumnTypeException.php +++ b/src/Db/Adapter/UnsupportedColumnTypeException.php @@ -11,7 +11,7 @@ use RuntimeException; /** - * Exception thrown when a column type doesn't match a Phinx type. + * Exception thrown when a column type doesn't match a known type. */ class UnsupportedColumnTypeException extends RuntimeException { diff --git a/src/Db/AlterInstructions.php b/src/Db/AlterInstructions.php index 9202ad7ba..39a20d1fa 100644 --- a/src/Db/AlterInstructions.php +++ b/src/Db/AlterInstructions.php @@ -8,6 +8,8 @@ namespace Migrations\Db; +use InvalidArgumentException; + /** * Contains all the information for running an ALTER command for a table, * and any post-steps required after the fact. @@ -24,6 +26,16 @@ class AlterInstructions */ protected array $postSteps = []; + /** + * @var string|null MySQL-specific: ALGORITHM clause + */ + protected ?string $algorithm = null; + + /** + * @var string|null MySQL-specific: LOCK clause + */ + protected ?string $lock = null; + /** * Constructor * @@ -87,12 +99,78 @@ public function getPostSteps(): array * Merges another AlterInstructions object to this one * * @param \Migrations\Db\AlterInstructions $other The other collection of instructions to merge in + * @throws \InvalidArgumentException When algorithm or lock specifications conflict * @return void */ public function merge(AlterInstructions $other): void { $this->alterParts = array_merge($this->alterParts, $other->getAlterParts()); $this->postSteps = array_merge($this->postSteps, $other->getPostSteps()); + + if ($other->getAlgorithm() !== null) { + if ($this->algorithm !== null && $this->algorithm !== $other->getAlgorithm()) { + throw new InvalidArgumentException(sprintf( + 'Conflicting algorithm specifications in batched operations: "%s" and "%s". ' . + 'All operations in a batch must use the same algorithm, or specify it on only one operation.', + $this->algorithm, + $other->getAlgorithm(), + )); + } + $this->algorithm = $other->getAlgorithm(); + } + if ($other->getLock() !== null) { + if ($this->lock !== null && $this->lock !== $other->getLock()) { + throw new InvalidArgumentException(sprintf( + 'Conflicting lock specifications in batched operations: "%s" and "%s". ' . + 'All operations in a batch must use the same lock mode, or specify it on only one operation.', + $this->lock, + $other->getLock(), + )); + } + $this->lock = $other->getLock(); + } + } + + /** + * Sets the ALGORITHM clause (MySQL-specific) + * + * @param string|null $algorithm The algorithm to use + * @return void + */ + public function setAlgorithm(?string $algorithm): void + { + $this->algorithm = $algorithm; + } + + /** + * Gets the ALGORITHM clause (MySQL-specific) + * + * @return string|null + */ + public function getAlgorithm(): ?string + { + return $this->algorithm; + } + + /** + * Sets the LOCK clause (MySQL-specific) + * + * @param string|null $lock The lock mode to use + * @return void + */ + public function setLock(?string $lock): void + { + $this->lock = $lock; + } + + /** + * Gets the LOCK clause (MySQL-specific) + * + * @return string|null + */ + public function getLock(): ?string + { + return $this->lock; } /** diff --git a/src/Db/InsertMode.php b/src/Db/InsertMode.php new file mode 100644 index 000000000..e04b707c1 --- /dev/null +++ b/src/Db/InsertMode.php @@ -0,0 +1,40 @@ +table = $table; } @@ -54,9 +54,9 @@ public function addAction(Action $action): void /** * Returns the table associated to this collection * - * @return \Migrations\Db\Table\Table + * @return \Migrations\Db\Table\TableMetadata */ - public function getTable(): Table + public function getTable(): TableMetadata { return $this->table; } diff --git a/src/Db/Plan/NewTable.php b/src/Db/Plan/NewTable.php index 5e0badbdd..826283b6a 100644 --- a/src/Db/Plan/NewTable.php +++ b/src/Db/Plan/NewTable.php @@ -10,7 +10,7 @@ use Migrations\Db\Table\Column; use Migrations\Db\Table\Index; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; /** * Represents the collection of actions for creating a new table @@ -20,9 +20,9 @@ class NewTable /** * The table to create * - * @var \Migrations\Db\Table\Table + * @var \Migrations\Db\Table\TableMetadata */ - protected Table $table; + protected TableMetadata $table; /** * The list of columns to add @@ -41,9 +41,9 @@ class NewTable /** * Constructor * - * @param \Migrations\Db\Table\Table $table The table to create + * @param \Migrations\Db\Table\TableMetadata $table The table to create */ - public function __construct(Table $table) + public function __construct(TableMetadata $table) { $this->table = $table; } @@ -73,9 +73,9 @@ public function addIndex(Index $index): void /** * Returns the table object associated to this collection * - * @return \Migrations\Db\Table\Table + * @return \Migrations\Db\Table\TableMetadata */ - public function getTable(): Table + public function getTable(): TableMetadata { return $this->table; } diff --git a/src/Db/Plan/Plan.php b/src/Db/Plan/Plan.php index db9647348..dcbaa718d 100644 --- a/src/Db/Plan/Plan.php +++ b/src/Db/Plan/Plan.php @@ -12,19 +12,22 @@ use Migrations\Db\Action\AddColumn; use Migrations\Db\Action\AddForeignKey; use Migrations\Db\Action\AddIndex; +use Migrations\Db\Action\AddPartition; use Migrations\Db\Action\ChangeColumn; use Migrations\Db\Action\ChangeComment; use Migrations\Db\Action\ChangePrimaryKey; use Migrations\Db\Action\CreateTable; use Migrations\Db\Action\DropForeignKey; use Migrations\Db\Action\DropIndex; +use Migrations\Db\Action\DropPartition; use Migrations\Db\Action\DropTable; use Migrations\Db\Action\RemoveColumn; use Migrations\Db\Action\RenameColumn; use Migrations\Db\Action\RenameTable; +use Migrations\Db\Action\SetPartitioning; use Migrations\Db\Adapter\AdapterInterface; use Migrations\Db\Plan\Solver\ActionSplitter; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; /** * A Plan takes an Intent and transforms int into a sequence of @@ -70,6 +73,13 @@ class Plan */ protected array $constraints = []; + /** + * List of partition additions or removals + * + * @var \Migrations\Db\Plan\AlterTable[] + */ + protected array $partitions = []; + /** * List of dropped columns * @@ -100,6 +110,7 @@ protected function createPlan(array $actions): void $this->gatherTableMoves($actions); $this->gatherIndexes($actions); $this->gatherConstraints($actions); + $this->gatherPartitions($actions); $this->resolveConflicts(); } @@ -114,6 +125,7 @@ protected function updatesSequence(): array $this->tableUpdates, $this->constraints, $this->indexes, + $this->partitions, $this->columnRemoves, $this->tableMoves, ]; @@ -129,6 +141,7 @@ protected function inverseUpdatesSequence(): array return [ $this->constraints, $this->tableMoves, + $this->partitions, $this->indexes, $this->columnRemoves, $this->tableUpdates, @@ -186,6 +199,7 @@ protected function resolveConflicts(): void $this->tableUpdates = $this->forgetTable($action->getTable(), $this->tableUpdates); $this->constraints = $this->forgetTable($action->getTable(), $this->constraints); $this->indexes = $this->forgetTable($action->getTable(), $this->indexes); + $this->partitions = $this->forgetTable($action->getTable(), $this->partitions); $this->columnRemoves = $this->forgetTable($action->getTable(), $this->columnRemoves); } } @@ -235,11 +249,11 @@ function (DropForeignKey $a, AddForeignKey $b) { * Deletes all actions related to the given table and keeps the * rest * - * @param \Migrations\Db\Table\Table $table The table to find in the list of actions + * @param \Migrations\Db\Table\TableMetadata $table The table to find in the list of actions * @param \Migrations\Db\Plan\AlterTable[] $actions The actions to transform * @return \Migrations\Db\Plan\AlterTable[] The list of actions without actions for the given table */ - protected function forgetTable(Table $table, array $actions): array + protected function forgetTable(TableMetadata $table, array $actions): array { $result = []; foreach ($actions as $action) { @@ -285,13 +299,13 @@ protected function remapContraintAndIndexConflicts(AlterTable $alter): AlterTabl /** * Deletes any DropIndex actions for the given table and exact columns * - * @param \Migrations\Db\Table\Table $table The table to find in the list of actions + * @param \Migrations\Db\Table\TableMetadata $table The table to find in the list of actions * @param string[] $columns The column names to match * @param \Migrations\Db\Plan\AlterTable[] $actions The actions to transform * @return array A tuple containing the list of actions without actions for dropping the index * and a list of drop index actions that were removed. */ - protected function forgetDropIndex(Table $table, array $columns, array $actions): array + protected function forgetDropIndex(TableMetadata $table, array $columns, array $actions): array { $dropIndexActions = new ArrayObject(); $indexes = array_map(function ($alter) use ($table, $columns, $dropIndexActions) { @@ -317,13 +331,13 @@ protected function forgetDropIndex(Table $table, array $columns, array $actions) /** * Deletes any RemoveColumn actions for the given table and exact columns * - * @param \Migrations\Db\Table\Table $table The table to find in the list of actions + * @param \Migrations\Db\Table\TableMetadata $table The table to find in the list of actions * @param string[] $columns The column names to match * @param \Migrations\Db\Plan\AlterTable[] $actions The actions to transform * @return array A tuple containing the list of actions without actions for removing the column * and a list of remove column actions that were removed. */ - protected function forgetRemoveColumn(Table $table, array $columns, array $actions): array + protected function forgetRemoveColumn(TableMetadata $table, array $columns, array $actions): array { $removeColumnActions = new ArrayObject(); $indexes = array_map(function ($alter) use ($table, $columns, $removeColumnActions) { @@ -490,4 +504,34 @@ protected function gatherConstraints(array $actions): void $this->constraints[$name]->addAction($action); } } + + /** + * Collects all partition creation and drops from the given intent + * + * @param \Migrations\Db\Action\Action[] $actions The actions to parse + * @return void + */ + protected function gatherPartitions(array $actions): void + { + foreach ($actions as $action) { + if ( + !($action instanceof AddPartition) + && !($action instanceof DropPartition) + && !($action instanceof SetPartitioning) + ) { + continue; + } elseif (isset($this->tableCreates[$action->getTable()->getName()])) { + continue; + } + + $table = $action->getTable(); + $name = $table->getName(); + + if (!isset($this->partitions[$name])) { + $this->partitions[$name] = new AlterTable($table); + } + + $this->partitions[$name]->addAction($action); + } + } } diff --git a/src/Db/Table.php b/src/Db/Table.php index d1c8a34ea..aeeb09066 100644 --- a/src/Db/Table.php +++ b/src/Db/Table.php @@ -14,25 +14,30 @@ use Migrations\Db\Action\AddColumn; use Migrations\Db\Action\AddForeignKey; use Migrations\Db\Action\AddIndex; +use Migrations\Db\Action\AddPartition; use Migrations\Db\Action\ChangeColumn; use Migrations\Db\Action\ChangeComment; use Migrations\Db\Action\ChangePrimaryKey; use Migrations\Db\Action\CreateTable; use Migrations\Db\Action\DropForeignKey; use Migrations\Db\Action\DropIndex; +use Migrations\Db\Action\DropPartition; use Migrations\Db\Action\DropTable; use Migrations\Db\Action\RemoveColumn; use Migrations\Db\Action\RenameColumn; use Migrations\Db\Action\RenameTable; +use Migrations\Db\Action\SetPartitioning; use Migrations\Db\Adapter\AdapterInterface; +use Migrations\Db\Adapter\MysqlAdapter; use Migrations\Db\Plan\Intent; use Migrations\Db\Plan\Plan; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; -use Migrations\Db\Table\Table as TableValue; +use Migrations\Db\Table\Partition; +use Migrations\Db\Table\PartitionDefinition; +use Migrations\Db\Table\TableMetadata; use RuntimeException; -use function Cake\Core\deprecationWarning; /** * Migration Table @@ -47,9 +52,9 @@ class Table { /** - * @var \Migrations\Db\Table\Table + * @var \Migrations\Db\Table\TableMetadata */ - protected TableValue $table; + protected TableMetadata $table; /** * @var \Migrations\Db\Adapter\AdapterInterface|null @@ -66,6 +71,27 @@ class Table */ protected array $data = []; + /** + * Insert mode for data operations + * + * @var \Migrations\Db\InsertMode|null + */ + protected ?InsertMode $insertMode = null; + + /** + * Columns to update on upsert conflict + * + * @var array|null + */ + protected ?array $upsertUpdateColumns = null; + + /** + * Columns that define uniqueness for upsert conflict detection + * + * @var array|null + */ + protected ?array $upsertConflictColumns = null; + /** * Primary key for this table. * Can either be a string or an array in case of composite @@ -82,7 +108,7 @@ class Table */ public function __construct(string $name, array $options = [], ?AdapterInterface $adapter = null) { - $this->table = new TableValue($name, $options); + $this->table = new TableMetadata($name, $options); $this->actions = new Intent(); if ($adapter !== null) { @@ -113,9 +139,9 @@ public function getOptions(): array /** * Gets the table name and options as an object * - * @return \Migrations\Db\Table\Table + * @return \Migrations\Db\Table\TableMetadata */ - public function getTable(): TableValue + public function getTable(): TableMetadata { return $this->table; } @@ -262,7 +288,7 @@ function ($column) use ($name) { /** * Sets an array of data to be inserted. * - * @param array $data Data + * @param array $data Data * @return $this */ public function setData(array $data) @@ -325,18 +351,16 @@ public function addPrimaryKey(string|array $columns) * Valid options can be: limit, default, null, precision or scale. * * @param string|\Migrations\Db\Table\Column $columnName Column Name - * @param string|\Migrations\Db\Literal|null $type Column Type + * @param string|null $type Column Type * @param array $options Column Options * @throws \InvalidArgumentException * @return $this */ - public function addColumn(string|Column $columnName, string|Literal|null $type = null, array $options = []) + public function addColumn(string|Column $columnName, ?string $type = null, array $options = []) { assert($columnName instanceof Column || $type !== null); if ($columnName instanceof Column) { $action = new AddColumn($this->table, $columnName); - } elseif ($type instanceof Literal) { - $action = AddColumn::build($this->table, $columnName, $type, $options); } else { $action = new AddColumn($this->table, $this->getAdapter()->getColumnForType($columnName, $type, $options)); } @@ -384,19 +408,78 @@ public function renameColumn(string $oldName, string $newName) return $this; } + /** + * Update a table column, preserving unspecified attributes. + * + * This is the recommended method for modifying columns as it automatically + * preserves existing column attributes (default, null, limit, etc.) unless + * explicitly overridden. + * + * @param string $columnName Column Name + * @param string|\Migrations\Db\Table\Column|null $newColumnType New Column Type (pass null to preserve existing type) + * @param array $options Options + * @return $this + */ + public function updateColumn(string $columnName, string|Column|null $newColumnType, array $options = []) + { + if (!($newColumnType instanceof Column)) { + $options['preserveUnspecified'] = true; + } + + return $this->changeColumn($columnName, $newColumnType, $options); + } + /** * Change a table column type. * + * Note: This method replaces the column definition. Consider using updateColumn() + * instead, which preserves unspecified attributes by default. + * * @param string $columnName Column Name - * @param string|\Migrations\Db\Table\Column|\Migrations\Db\Literal $newColumnType New Column Type + * @param string|\Migrations\Db\Table\Column|null $newColumnType New Column Type (pass null to preserve existing type) * @param array $options Options * @return $this */ - public function changeColumn(string $columnName, string|Column|Literal $newColumnType, array $options = []) + public function changeColumn(string $columnName, string|Column|null $newColumnType, array $options = []) { if ($newColumnType instanceof Column) { + if ($options) { + throw new InvalidArgumentException( + 'Cannot specify options array when passing a Column object. ' . + 'Set all properties directly on the Column object instead.', + ); + } $action = new ChangeColumn($this->table, $columnName, $newColumnType); } else { + // Check if we should preserve existing column attributes + $preserveUnspecified = $options['preserveUnspecified'] ?? false; // Default to false for BC + unset($options['preserveUnspecified']); + + // If type is null, preserve the existing type + if ($newColumnType === null) { + if (!$this->hasColumn($columnName)) { + throw new RuntimeException( + "Cannot preserve column type for '$columnName' - column does not exist in table '{$this->getName()}'", + ); + } + $existingColumn = $this->getColumn($columnName); + if ($existingColumn === null) { + throw new RuntimeException( + "Cannot retrieve column definition for '$columnName' in table '{$this->getName()}'", + ); + } + $newColumnType = $existingColumn->getType(); + } + + if ($preserveUnspecified && $this->hasColumn($columnName)) { + // Get existing column definition + $existingColumn = $this->getColumn($columnName); + if ($existingColumn !== null) { + // Merge existing attributes with new ones + $options = $this->mergeColumnOptions($existingColumn, $newColumnType, $options); + } + } + $action = ChangeColumn::build($this->table, $columnName, $newColumnType, $options); } $this->actions->addAction($action); @@ -489,12 +572,12 @@ public function hasIndexByName(string $indexName): bool * on_update, constraint = constraint name. * * @param string|string[]|\Migrations\Db\Table\ForeignKey $columns Columns - * @param string|\Migrations\Db\Table\Table $referencedTable Referenced Table + * @param string|\Migrations\Db\Table\TableMetadata $referencedTable Referenced Table * @param string|string[] $referencedColumns Referenced Columns * @param array $options Options * @return $this */ - public function addForeignKey(string|array|ForeignKey $columns, string|TableValue|null $referencedTable = null, string|array $referencedColumns = ['id'], array $options = []) + public function addForeignKey(string|array|ForeignKey $columns, string|TableMetadata|null $referencedTable = null, string|array $referencedColumns = ['id'], array $options = []) { if ($columns instanceof ForeignKey) { $action = new AddForeignKey($this->table, $columns); @@ -509,55 +592,6 @@ public function addForeignKey(string|array|ForeignKey $columns, string|TableValu return $this; } - /** - * Add a foreign key to a database table with a given name. - * - * In $options you can specify on_delete|on_delete = cascade|no_action .., - * on_update, constraint = constraint name. - * - * @param string|\Migrations\Db\Table\ForeignKey $name The constraint name or a foreign key object. - * @param string|string[] $columns Columns - * @param string|\Migrations\Db\Table\Table $referencedTable Referenced Table - * @param string|string[] $referencedColumns Referenced Columns - * @param array $options Options - * @return $this - * @deprecated 4.6.0 Use addForeignKey() instead. Use `BaseMigration::foreignKey()` to get - * a fluent interface for building foreign keys. - */ - public function addForeignKeyWithName( - string|ForeignKey $name, - string|array|null $columns = null, - string|TableValue|null $referencedTable = null, - string|array $referencedColumns = ['id'], - array $options = [], - ) { - deprecationWarning( - '4.6.0', - 'Use addForeignKey() instead. Use `BaseMigration::foreignKey()` to get a fluent' . - ' interface for building foreign keys.', - ); - if (is_string($name)) { - if ($columns === null || $referencedTable === null) { - throw new InvalidArgumentException( - 'Columns and referencedTable are required when adding a foreign key with a name', - ); - } - $action = AddForeignKey::build( - $this->table, - $columns, - $referencedTable, - $referencedColumns, - $options, - $name, - ); - } else { - $action = new AddForeignKey($this->table, $name); - } - $this->actions->addAction($action); - - return $this; - } - /** * Removes the given foreign key from the table. * @@ -585,6 +619,84 @@ public function hasForeignKey(string|array $columns, ?string $constraint = null) return $this->getAdapter()->hasForeignKey($this->getName(), $columns, $constraint); } + /** + * Add partitioning to the table. + * + * @param string $type Partition type (RANGE, LIST, HASH, KEY) + * @param string|string[]|\Migrations\Db\Literal $columns Column(s) or expression to partition by + * @param array $options Partition options (count for HASH/KEY) + * @return $this + */ + public function partitionBy(string $type, string|array|Literal $columns, array $options = []) + { + $partition = new Partition($type, $columns, [], $options['count'] ?? null, $options); + $this->table->setPartition($partition); + + return $this; + } + + /** + * Add a partition definition (for RANGE/LIST types). + * + * @param string $name Partition name + * @param mixed $value Boundary value (use 'MAXVALUE' for RANGE upper bound) + * @param array $options Additional options (tablespace, table for PG) + * @return $this + */ + public function addPartition(string $name, mixed $value = null, array $options = []) + { + $partition = $this->table->getPartition(); + if ($partition === null) { + throw new RuntimeException('Must call partitionBy() before addPartition()'); + } + + $definition = new PartitionDefinition( + $name, + $value, + $options['tablespace'] ?? null, + $options['table'] ?? null, + $options['comment'] ?? null, + ); + $partition->addDefinition($definition); + + return $this; + } + + /** + * Remove a partition from an existing table. + * + * @param string $name Partition name + * @return $this + */ + public function dropPartition(string $name) + { + $this->actions->addAction(new DropPartition($this->table, $name)); + + return $this; + } + + /** + * Add a partition to an existing partitioned table. + * + * @param string $name Partition name + * @param mixed $value Boundary value + * @param array $options Additional options + * @return $this + */ + public function addPartitionToExisting(string $name, mixed $value, array $options = []) + { + $definition = new PartitionDefinition( + $name, + $value, + $options['tablespace'] ?? null, + $options['table'] ?? null, + $options['comment'] ?? null, + ); + $this->actions->addAction(new AddPartition($this->table, $definition)); + + return $this; + } + /** * Add timestamp columns created_at and updated_at to the table. * @@ -673,6 +785,64 @@ public function insert(array $data) return $this; } + /** + * Insert data into the table, skipping rows that would cause duplicate key conflicts. + * + * This method is idempotent and safe to run multiple times. + * + * @param array $data array of data in the same format as insert() + * @return $this + */ + public function insertOrSkip(array $data) + { + $this->insertMode = InsertMode::IGNORE; + + return $this->insert($data); + } + + /** + * Insert data into the table, updating specified columns on duplicate key conflicts. + * + * This method performs an "upsert" operation - inserting new rows and updating + * existing rows that conflict on the specified unique columns. + * + * ### Database-specific behavior: + * + * - **MySQL**: Uses `ON DUPLICATE KEY UPDATE`. The `$conflictColumns` parameter is + * ignored because MySQL automatically applies the update to all unique constraint + * violations. Passing `$conflictColumns` will trigger a warning. + * + * - **PostgreSQL/SQLite**: Uses `ON CONFLICT (...) DO UPDATE SET`. The `$conflictColumns` + * parameter is required and must specify the columns that have a unique constraint. + * A RuntimeException will be thrown if this parameter is empty. + * + * - **SQL Server**: Not currently supported. Use separate insert/update logic. + * + * ### Example: + * ```php + * // Works on all supported databases + * $table->insertOrUpdate([ + * ['code' => 'USD', 'rate' => 1.0000], + * ['code' => 'EUR', 'rate' => 0.9234], + * ], ['rate'], ['code']); + * ``` + * + * @param array $data array of data in the same format as insert() + * @param array $updateColumns Columns to update when a conflict occurs + * @param array $conflictColumns Columns that define uniqueness. Required for PostgreSQL/SQLite, + * ignored by MySQL (triggers warning if provided). + * @return $this + * @throws \RuntimeException When using PostgreSQL or SQLite without specifying conflictColumns + */ + public function insertOrUpdate(array $data, array $updateColumns, array $conflictColumns) + { + $this->insertMode = InsertMode::UPSERT; + $this->upsertUpdateColumns = $updateColumns; + $this->upsertConflictColumns = $conflictColumns; + + return $this->insert($data); + } + /** * Creates a table from the object instance. * @@ -687,19 +857,10 @@ public function create(): void } $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']; + if ($adapter instanceof MysqlAdapter && empty($options['collation'])) { + $collation = $adapter->getDefaultCollation(); + if ($collation) { + $options['collation'] = $collation; } } @@ -714,8 +875,8 @@ public function create(): void * 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. + * when defining the column. Migrations 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 */ @@ -731,12 +892,13 @@ protected function filterPrimaryKey(array $options): void } $primaryKey = array_flip($primaryKey); + /** @var \Cake\Collection\Collection $columnsCollection */ $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 */ + /** @var \Migrations\Db\Action\ChangeColumn|\Migrations\Db\Action\RenameColumn|\Migrations\Db\Action\RemoveColumn|\Migrations\Db\Action\AddColumn $action */ return $action->getColumn(); }); $primaryKeyColumns = $columnsCollection->filter(function (Column $columnDef, $key) use ($primaryKey) { @@ -800,14 +962,29 @@ public function saveData(): void } if ($bulk) { - $this->getAdapter()->bulkinsert($this->table, $this->getData()); + $this->getAdapter()->bulkinsert( + $this->table, + $this->getData(), + $this->insertMode, + $this->upsertUpdateColumns, + $this->upsertConflictColumns, + ); } else { foreach ($this->getData() as $row) { - $this->getAdapter()->insert($this->table, $row); + $this->getAdapter()->insert( + $this->table, + $row, + $this->insertMode, + $this->upsertUpdateColumns, + $this->upsertConflictColumns, + ); } } $this->resetData(); + $this->insertMode = null; + $this->upsertUpdateColumns = null; + $this->upsertConflictColumns = null; } /** @@ -856,6 +1033,14 @@ protected function executeActions(bool $exists): void } } + // If table exists and has partition configuration, create SetPartitioning action + if ($exists) { + $partition = $this->table->getPartition(); + if ($partition !== null && $partition->getDefinitions()) { + $this->actions->addAction(new SetPartitioning($this->table, $partition)); + } + } + // If the table does not exist, the last command in the chain needs to be // a CreateTable action. if (!$exists) { @@ -865,4 +1050,88 @@ protected function executeActions(bool $exists): void $plan = new Plan($this->actions); $plan->execute($this->getAdapter()); } + + /** + * Merges existing column options with new options. + * Only attributes that are explicitly specified in the new options will override existing ones. + * + * @param \Migrations\Db\Table\Column $existingColumn Existing column definition + * @param string $newColumnType New column type + * @param array $options New options + * @return array Merged options + */ + protected function mergeColumnOptions(Column $existingColumn, string $newColumnType, array $options): array + { + // Determine if type is changing + $newTypeString = (string)$newColumnType; + $existingTypeString = (string)$existingColumn->getType(); + $typeChanging = $newTypeString !== $existingTypeString; + + // Build array of existing column attributes + $existingOptions = []; + + // Only preserve limit if type is not changing or limit is not explicitly set + if (!$typeChanging && !array_key_exists('limit', $options) && !array_key_exists('length', $options)) { + $limit = $existingColumn->getLimit(); + if ($limit !== null) { + $existingOptions['limit'] = $limit; + } + } + + // Preserve default if not explicitly set + if (!array_key_exists('default', $options)) { + $existingOptions['default'] = $existingColumn->getDefault(); + } + + // Preserve null if not explicitly set + if (!isset($options['null'])) { + $existingOptions['null'] = $existingColumn->getNull(); + } + + // Preserve scale/precision if not explicitly set + if (!array_key_exists('scale', $options) && !array_key_exists('precision', $options)) { + $scale = $existingColumn->getScale(); + if ($scale !== null) { + $existingOptions['scale'] = $scale; + } + $precision = $existingColumn->getPrecision(); + if ($precision !== null) { + $existingOptions['precision'] = $precision; + } + } + + // Preserve comment if not explicitly set + if (!array_key_exists('comment', $options)) { + $comment = $existingColumn->getComment(); + if ($comment !== null) { + $existingOptions['comment'] = $comment; + } + } + + // Preserve signed if not explicitly set (always has a value) + if (!isset($options['signed'])) { + $existingOptions['signed'] = $existingColumn->getSigned(); + } + + // Preserve collation if not explicitly set + if (!isset($options['collation'])) { + $collation = $existingColumn->getCollation(); + if ($collation !== null) { + $existingOptions['collation'] = $collation; + } + } + + // Preserve encoding if not explicitly set + if (!isset($options['encoding'])) { + $encoding = $existingColumn->getEncoding(); + if ($encoding !== null) { + $existingOptions['encoding'] = $encoding; + } + } + + // Note: enum/set values are not preserved as schema reflection doesn't populate them + + // New options override existing ones + return array_merge($existingOptions, $options); + } } diff --git a/src/Db/Table/CheckConstraint.php b/src/Db/Table/CheckConstraint.php new file mode 100644 index 000000000..dd0a44f2c --- /dev/null +++ b/src/Db/Table/CheckConstraint.php @@ -0,0 +1,20 @@ + [ + * 'unsigned_primary_keys' => true, + * 'unsigned_ints' => true, + * ] + * ``` + * + * Note: Explicitly calling setUnsigned() or setSigned() on a column will override these defaults. */ -class Column +class Column extends DatabaseColumn { public const BIGINTEGER = TableSchemaInterface::TYPE_BIGINTEGER; public const SMALLINTEGER = TableSchemaInterface::TYPE_SMALLINTEGER; @@ -40,100 +60,33 @@ class Column public const BINARYUUID = TableSchemaInterface::TYPE_BINARY_UUID; public const NATIVEUUID = TableSchemaInterface::TYPE_NATIVE_UUID; /** MySQL-only column type */ - public const MEDIUMINTEGER = AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER; - /** MySQL-only column type */ - public const ENUM = AdapterInterface::PHINX_TYPE_ENUM; - /** MySQL-only column type */ - public const SET = AdapterInterface::PHINX_TYPE_STRING; - /** MySQL-only column type */ - public const BLOB = AdapterInterface::PHINX_TYPE_BLOB; - /** MySQL-only column type */ - public const YEAR = AdapterInterface::PHINX_TYPE_YEAR; + public const YEAR = TableSchemaInterface::TYPE_YEAR; /** MySQL/Postgres-only column type */ public const JSON = TableSchemaInterface::TYPE_JSON; /** Postgres-only column type */ - public const JSONB = AdapterInterface::PHINX_TYPE_JSONB; + public const CIDR = TableSchemaInterface::TYPE_CIDR; /** Postgres-only column type */ - public const CIDR = AdapterInterface::PHINX_TYPE_CIDR; + public const INET = TableSchemaInterface::TYPE_INET; /** Postgres-only column type */ - public const INET = AdapterInterface::PHINX_TYPE_INET; + public const MACADDR = TableSchemaInterface::TYPE_MACADDR; /** Postgres-only column type */ - public const MACADDR = AdapterInterface::PHINX_TYPE_MACADDR; - /** Postgres-only column type */ - public const INTERVAL = AdapterInterface::PHINX_TYPE_INTERVAL; - - /** - * @var string - */ - protected string $name = ''; - - /** - * @var string|\Migrations\Db\Literal - */ - protected string|Literal $type; - - /** - * @var int|null - */ - protected ?int $limit = null; - - /** - * @var bool - */ - protected bool $null = true; - - /** - * @var mixed - */ - protected mixed $default = null; - - /** - * @var bool - */ - protected bool $identity = false; - - /** - * Postgres-only column option for identity (always|default) - * - * @var ?string - */ - protected ?string $generated = PostgresAdapter::GENERATED_BY_DEFAULT; + public const INTERVAL = TableSchemaInterface::TYPE_INTERVAL; /** * @var int|null */ protected ?int $seed = null; - /** - * @var int|null - */ - protected ?int $increment = null; - /** * @var int|null */ protected ?int $scale = null; - /** - * @var string|null - */ - protected ?string $after = null; - /** * @var string|null */ protected ?string $update = null; - /** - * @var string|null - */ - protected ?string $comment = null; - - /** - * @var bool - */ - protected bool $signed = true; - /** * @var bool */ @@ -150,26 +103,62 @@ class Column protected ?string $collation = null; /** - * @var string|null + * @var array|null */ - protected ?string $encoding = null; + protected ?array $values = null; /** - * @var int|null + * @var string|null */ - protected ?int $srid = null; + protected ?string $algorithm = null; /** - * @var array|null + * @var string|null */ - protected ?array $values = null; + protected ?string $lock = null; /** * Column constructor - */ - public function __construct() - { - $this->null = (bool)Configure::read('Migrations.column_null_default'); + * + * @param string $name The name of the column. + * @param string $type The type of the column. + * @param bool|null $null Whether the column allows nulls. + * @param mixed $default The default value for the column. + * @param int|null $length The length of the column. + * @param bool $identity Whether the column is an identity column. + * @param string|null $generated Postgres-only generated option for identity columns (always|default). + * @param int|null $precision The precision for decimal columns. + * @param int|null $increment The increment for identity columns. + * @param string|null $after The column to add this column after. + * @param string|null $onUpdate The ON UPDATE function for the column. + * @param string|null $comment The comment for the column. + * @param bool|null $unsigned Whether the column is unsigned. + * @param string|null $collate The collation for the column. + * @param int|null $srid The SRID for spatial columns. + * @param string|null $encoding The character set encoding for the column. + * @param string|null $baseType The base type for the column. + * @return void + */ + public function __construct( + protected string $name = '', + protected string $type = '', + protected ?bool $null = null, + protected mixed $default = null, + protected ?int $length = null, + protected bool $identity = false, + protected ?string $generated = PostgresAdapter::GENERATED_BY_DEFAULT, + protected ?int $precision = null, + protected ?int $increment = null, + protected ?string $after = null, + protected ?string $onUpdate = null, + protected ?string $comment = null, + protected ?bool $unsigned = null, + protected ?string $collate = null, + protected ?int $srid = null, + protected ?string $encoding = null, + protected ?string $baseType = null, + ) { + $this->null = $null ?? (bool)Configure::read('Migrations.column_null_default'); } /** @@ -195,38 +184,16 @@ public function getName(): ?string return $this->name; } - /** - * Sets the column type. - * - * @param string|\Migrations\Db\Literal $type Column type - * @return $this - */ - public function setType(string|Literal $type) - { - $this->type = $type; - - return $this; - } - - /** - * Gets the column type. - * - * @return string|\Migrations\Db\Literal - */ - public function getType(): string|Literal - { - return $this->type; - } - /** * Sets the column limit. * * @param int|null $limit Limit * @return $this + * @deprecated 5.0 Use setLength() instead. */ public function setLimit(?int $limit) { - $this->limit = $limit; + $this->length = $limit; return $this; } @@ -235,10 +202,11 @@ public function setLimit(?int $limit) * Gets the column limit. * * @return int|null + * @deprecated 5.0 Use getLength() instead. */ public function getLimit(): ?int { - return $this->limit; + return $this->length; } /** @@ -422,10 +390,11 @@ public function setPrecision(?int $precision) * and the column could store value from -999.99 to 999.99. * * @return int|null + * @deprecated 5.0 Use getLength() instead. */ public function getPrecision(): ?int { - return $this->limit; + return $this->length; } /** @@ -545,36 +514,85 @@ public function getComment(): ?string } /** - * Sets whether field should be signed. + * Gets whether field should be unsigned. * - * @param bool $signed Signed + * Checks configuration options to determine unsigned behavior: + * - If explicitly set via setUnsigned/setSigned, uses that value + * - If identity column and Migrations.unsigned_primary_keys is true, returns true + * - If integer type and Migrations.unsigned_ints is true, returns true + * - Otherwise defaults to false (signed) + * + * @return bool + */ + public function getUnsigned(): bool + { + // If explicitly set, use that value + if ($this->unsigned !== null) { + return $this->unsigned; + } + + $integerTypes = [ + self::INTEGER, + self::BIGINTEGER, + self::SMALLINTEGER, + self::TINYINTEGER, + ]; + + // Only apply configuration to integer types + if (!in_array($this->type, $integerTypes, true)) { + return false; + } + + // Check if this is a primary key/identity column + if ($this->identity && Configure::read('Migrations.unsigned_primary_keys')) { + return true; + } + + // Check general integer configuration + if (Configure::read('Migrations.unsigned_ints')) { + return true; + } + + // Default to signed for backward compatibility + return false; + } + + /** + * Sets whether field should be unsigned. + * + * @param bool $unsigned Unsigned * @return $this */ - public function setSigned(bool $signed) + public function setUnsigned(bool $unsigned) { - $this->signed = $signed; + $this->unsigned = $unsigned; return $this; } /** - * Gets whether field should be signed. + * Sets whether field should be signed. * - * @return bool + * @param bool $signed Signed + * @return $this + * @deprecated 5.0 Use setUnsigned() instead. */ - public function getSigned(): bool + public function setSigned(bool $signed) { - return $this->signed; + $this->unsigned = !$signed; + + return $this; } /** - * Should the column be signed? + * Gets whether field should be signed. * * @return bool + * @deprecated 5.0 Use getUnsigned() instead. */ - public function isSigned(): bool + public function getSigned(): bool { - return $this->getSigned(); + return !$this->isUnsigned(); } /** @@ -665,10 +683,11 @@ public function getValues(): ?array * * @param string $collation Collation * @return $this + * @deprecated 5.0 Use setCollate() instead. */ public function setCollation(string $collation) { - $this->collation = $collation; + $this->collate = $collation; return $this; } @@ -677,10 +696,11 @@ public function setCollation(string $collation) * Gets the column collation. * * @return string|null + * @deprecated 5.0 Use getCollate() instead. */ public function getCollation(): ?string { - return $this->collation; + return $this->collate; } /** @@ -707,26 +727,49 @@ public function getEncoding(): ?string } /** - * Sets the column SRID. + * Sets the ALTER TABLE algorithm (MySQL-specific). * - * @param int $srid SRID + * @param string $algorithm Algorithm * @return $this */ - public function setSrid(int $srid) + public function setAlgorithm(string $algorithm) { - $this->srid = $srid; + $this->algorithm = $algorithm; return $this; } /** - * Gets the column SRID. + * Gets the ALTER TABLE algorithm. * - * @return int|null + * @return string|null + */ + public function getAlgorithm(): ?string + { + return $this->algorithm; + } + + /** + * Sets the ALTER TABLE lock mode (MySQL-specific). + * + * @param string $lock Lock mode + * @return $this + */ + public function setLock(string $lock) + { + $this->lock = $lock; + + return $this; + } + + /** + * Gets the ALTER TABLE lock mode. + * + * @return string|null */ - public function getSrid(): ?int + public function getLock(): ?string { - return $this->srid; + return $this->lock; } /** @@ -746,15 +789,19 @@ protected function getValidOptions(): array 'update', 'comment', 'signed', + 'unsigned', 'timezone', 'properties', 'values', 'collation', + 'collate', 'encoding', 'srid', 'seed', 'increment', 'generated', + 'algorithm', + 'lock', ]; } @@ -769,6 +816,7 @@ protected function getAliasedOptions(): array 'length' => 'limit', 'precision' => 'limit', 'autoIncrement' => 'identity', + 'collation' => 'collate', ]; } @@ -844,7 +892,8 @@ public function toArray(): array 'length' => $length, 'null' => $this->getNull(), 'default' => $default, - 'unsigned' => !$this->getSigned(), + 'generated' => $this->getGenerated(), + 'unsigned' => $this->getUnsigned(), 'onUpdate' => $this->getUpdate(), 'collate' => $this->getCollation(), 'precision' => $precision, diff --git a/src/Db/Table/ForeignKey.php b/src/Db/Table/ForeignKey.php index 90da53e29..907e5f375 100644 --- a/src/Db/Table/ForeignKey.php +++ b/src/Db/Table/ForeignKey.php @@ -8,9 +8,9 @@ namespace Migrations\Db\Table; +use Cake\Database\Schema\ForeignKey as DatabaseForeignKey; use InvalidArgumentException; use RuntimeException; -use function Cake\Core\deprecationWarning; /** * Foreign key value object @@ -20,7 +20,7 @@ * @see \Migrations\BaseMigration::foreignKey() * @see \Migrations\Db\Table::addForeignKey() */ -class ForeignKey +class ForeignKey extends DatabaseForeignKey { public const CASCADE = 'CASCADE'; public const RESTRICT = 'RESTRICT'; @@ -31,303 +31,246 @@ class ForeignKey public const NOT_DEFERRED = 'NOT DEFERRABLE'; /** + * An allow list of valid actions + * + * Both the constant values from CakePHP and backwards compatibility with migrations + * are supported. + * * @var array */ - protected static array $validOptions = ['delete', 'update', 'constraint', 'name', 'deferrable']; - - /** - * @var string[] - */ - protected array $columns = []; - - /** - * @var \Migrations\Db\Table\Table - */ - protected Table $referencedTable; - - /** - * @var string[] - */ - protected array $referencedColumns = []; - - /** - * @var string|null - */ - protected ?string $onDelete = null; - - /** - * @var string|null - */ - protected ?string $onUpdate = null; - - /** - * @var string|null - */ - protected ?string $name = null; + protected array $validActions = [ + DatabaseForeignKey::CASCADE, + DatabaseForeignKey::RESTRICT, + DatabaseForeignKey::SET_NULL, + DatabaseForeignKey::NO_ACTION, + DatabaseForeignKey::SET_DEFAULT, + self::CASCADE, + self::RESTRICT, + self::SET_NULL, + self::NO_ACTION, + self::SET_DEFAULT, + 'NO_ACTION', + 'SET_DEFAULT', + 'SET_NULL', + ]; /** - * @var string|null - */ - protected ?string $deferrableMode = null; - - /** - * Sets the foreign key columns. - * - * @param string[]|string $columns Columns - * @return $this + * @var array */ - public function setColumns(array|string $columns) - { - $this->columns = is_string($columns) ? [$columns] : $columns; - - return $this; - } + protected static array $validOptions = ['delete', 'update', 'constraint', 'name', 'deferrable']; /** - * Gets the foreign key columns. + * Constructor * - * @return string[] + * @param string $name The name of the index. + * @param array $columns The columns to index. + * @param ?string $referencedTable The columns to index. + * @param array $referencedColumns The columns in $referencedTable that this key references. + * @param ?string $delete The action to take when the referenced row is deleted. + * @param ?string $update The action to take when the referenced row is updated. */ - public function getColumns(): array - { - return $this->columns; + public function __construct( + protected string $name = '', + protected array $columns = [], + protected ?string $referencedTable = null, + protected array $referencedColumns = [], + ?string $delete = null, + ?string $update = null, + ?string $deferrable = null, + ) { + $this->type = self::FOREIGN; + $this->delete = $this->normalizeAction($delete ?? self::NO_ACTION); + $this->update = $this->normalizeAction($update ?? self::NO_ACTION); + if ($deferrable) { + $this->deferrable = $this->normalizeDeferrable($deferrable); + } } /** - * Sets the foreign key referenced table. + * Utility method that maps an array of index options to this object's methods. * - * @param \Migrations\Db\Table\Table|string $table The table this KEY is pointing to + * @param array $options Options + * @throws \RuntimeException * @return $this */ - public function setReferencedTable(Table|string $table) + public function setOptions(array $options) { - if (is_string($table)) { - $table = new Table($table); + foreach ($options as $option => $value) { + if (!in_array($option, static::$validOptions, true)) { + throw new RuntimeException(sprintf('"%s" is not a valid foreign key option.', $option)); + } + + // handle $options['delete'] as $options['update'] + if ($option === 'delete') { + $this->setOnDelete($value); + } elseif ($option === 'update') { + $this->setOnUpdate($value); + } elseif ($option === 'deferrable') { + $this->setDeferrableMode($value); + } else { + $method = 'set' . ucfirst($option); + $this->$method($value); + } } - $this->referencedTable = $table; return $this; } /** - * Gets the foreign key referenced table. + * Convert from migrations sql snippet to cakephp/database constant names. * - * @return \Migrations\Db\Table\Table + * @param string $action The action to normalize + * @return string */ - public function getReferencedTable(): Table + protected function normalizeAction(string $action): string { - return $this->referencedTable; + $action = str_replace(' ', '_', strtoupper(trim($action))); + $result = parent::normalizeAction($action); + + return match ($result) { + self::CASCADE => DatabaseForeignKey::CASCADE, + self::RESTRICT => DatabaseForeignKey::RESTRICT, + self::SET_NULL => DatabaseForeignKey::SET_NULL, + self::NO_ACTION => DatabaseForeignKey::NO_ACTION, + self::SET_DEFAULT => DatabaseForeignKey::SET_DEFAULT, + 'NO_ACTION' => DatabaseForeignKey::NO_ACTION, + 'SET_NULL' => DatabaseForeignKey::SET_NULL, + 'SET_DEFAULT' => DatabaseForeignKey::SET_DEFAULT, + default => throw new InvalidArgumentException(sprintf('Invalid foreign key action: %s', $action)), + }; } /** - * Sets the foreign key referenced columns. + * Map between cakephp/database constant names and + * migrations sql snippets. * - * @param string|string[] $referencedColumns Referenced columns - * @return $this - */ - public function setReferencedColumns(array|string $referencedColumns) - { - $referencedColumns = is_string($referencedColumns) ? [$referencedColumns] : $referencedColumns; - $this->referencedColumns = $referencedColumns; - - return $this; - } - - /** - * Gets the foreign key referenced columns. + * These constants are different for backwards compatibility reasons. + * Longer term, there should probably be a public API in cakephp/database + * for converting between constants and sql. * - * @return string[] + * @param string $action The action to map + * @return string */ - public function getReferencedColumns(): array + protected function mapAction(string $action): string { - return $this->referencedColumns; + return match ($action) { + DatabaseForeignKey::CASCADE => self::CASCADE, + DatabaseForeignKey::RESTRICT => self::RESTRICT, + DatabaseForeignKey::SET_NULL => self::SET_NULL, + DatabaseForeignKey::NO_ACTION => self::NO_ACTION, + default => $action, + }; } /** - * Sets ON DELETE action for the foreign key. + * Sets deferrable mode for the foreign key. * - * @param string $onDelete On Delete + * @param string $deferrableMode Constraint * @return $this */ - public function setOnDelete(string $onDelete) + public function setDeferrableMode(string $deferrableMode) { - $this->onDelete = $this->normalizeAction($onDelete); + $this->deferrable = $this->normalizeDeferrable($deferrableMode); return $this; } /** - * Gets ON DELETE action for the foreign key. - * - * @return string|null - */ - public function getOnDelete(): ?string - { - return $this->onDelete; - } - - /** - * Gets ON UPDATE action for the foreign key. - * - * @return string|null + * Gets deferrable mode for the foreign key. */ - public function getOnUpdate(): ?string + public function getDeferrableMode(): ?string { - return $this->onUpdate; + return $this->mapDeferrable($this->deferrable); } /** - * Sets ON UPDATE action for the foreign key. + * Convert from migrations sql snippet to cakephp/database constant names. * - * @param string $onUpdate On Update - * @return $this + * @param string $action The action to normalize + * @return string */ - public function setOnUpdate(string $onUpdate) + protected function normalizeDeferrable(string $action): string { - $this->onUpdate = $this->normalizeAction($onUpdate); - - return $this; + $result = parent::normalizeDeferrable($action); + + return match ($result) { + self::DEFERRED => DatabaseForeignKey::DEFERRED, + self::IMMEDIATE => DatabaseForeignKey::IMMEDIATE, + self::NOT_DEFERRED => DatabaseForeignKey::NOT_DEFERRED, + 'NOT_DEFERRED' => DatabaseForeignKey::NOT_DEFERRED, + default => throw new InvalidArgumentException(sprintf('Invalid foreign key deferrable: %s', $action)), + }; } /** - * Set the constraint name for the foreign key. + * Map between cakephp/database constant names and + * migrations sql snippets. * - * @param string $name Constraint name - * @return $this - */ - public function setName(string $name) - { - $this->name = $name; - - return $this; - } - - /** - * Get the constraint name if set. + * These constants are different for backwards compatibility reasons. + * Longer term, there should probably be a public API in cakephp/database + * for converting between constants and sql. * - * @return string|null + * @param string $action The action to map + * @return ?string */ - public function getName(): ?string + protected function mapDeferrable(?string $action): ?string { - return $this->name; + return match ($action) { + DatabaseForeignKey::DEFERRED => self::DEFERRED, + DatabaseForeignKey::IMMEDIATE => self::IMMEDIATE, + DatabaseForeignKey::NOT_DEFERRED => self::NOT_DEFERRED, + null => null, + default => $action, + }; } /** - * Sets constraint for the foreign key. + * Sets ON DELETE action for the foreign key. * - * @param string $constraint Constraint + * @param string $onDelete On Delete action * @return $this + * @deprecated 5.0 Use setDelete() instead. */ - public function setConstraint(string $constraint) + public function setOnDelete(string $onDelete) { - deprecationWarning('4.6.0', 'setConstraint() is deprecated. Use setName() instead.'); - $this->name = $constraint; + $this->delete = $this->normalizeAction($onDelete); return $this; } /** - * Gets constraint name for the foreign key. + * Gets ON DELETE action for the foreign key. * * @return string|null + * @deprecated 5.0 Use getDelete() instead. */ - public function getConstraint(): ?string - { - deprecationWarning('4.6.0', 'getConstraint() is deprecated. Use getName() instead.'); - - return $this->name; - } - - /** - * Sets deferrable mode for the foreign key. - * - * @param string $deferrableMode Constraint - * @return $this - */ - public function setDeferrableMode(string $deferrableMode) - { - $this->deferrableMode = $this->normalizeDeferrable($deferrableMode); - - return $this; - } - - /** - * Gets deferrable mode for the foreign key. - */ - public function getDeferrableMode(): ?string + public function getOnDelete(): ?string { - return $this->deferrableMode; + return $this->mapAction($this->getDelete()); } /** - * Utility method that maps an array of index options to this object's methods. + * Sets ON UPDATE action for the foreign key. * - * @param array $options Options - * @throws \RuntimeException + * @param string $onUpdate On update action * @return $this + * @deprecated 5.0 Use setUpdate() instead. */ - public function setOptions(array $options) + public function setOnUpdate(string $onUpdate) { - foreach ($options as $option => $value) { - if (!in_array($option, static::$validOptions, true)) { - throw new RuntimeException(sprintf('"%s" is not a valid foreign key option.', $option)); - } - - // handle $options['delete'] as $options['update'] - if ($option === 'delete') { - $this->setOnDelete($value); - } elseif ($option === 'update') { - $this->setOnUpdate($value); - } elseif ($option === 'deferrable') { - $this->setDeferrableMode($value); - } else { - $method = 'set' . ucfirst($option); - $this->$method($value); - } - } + $this->update = $this->normalizeAction($onUpdate); return $this; } /** - * From passed value checks if it's correct and fixes if needed - * - * @param string $action Action - * @throws \InvalidArgumentException - * @return string - */ - protected function normalizeAction(string $action): string - { - $constantName = 'static::' . str_replace(' ', '_', strtoupper(trim($action))); - if (!defined($constantName)) { - throw new InvalidArgumentException('Unknown action passed: ' . $action); - } - - return constant($constantName); - } - - /** - * From passed value checks if it's correct and fixes if needed + * Gets ON UPDATE action for the foreign key. * - * @param string $deferrable Deferrable - * @throws \InvalidArgumentException - * @return string + * @return string|null + * @deprecated 5.0 Use getUpdate() instead. */ - protected function normalizeDeferrable(string $deferrable): string + public function getOnUpdate(): ?string { - $mapping = [ - 'DEFERRED' => ForeignKey::DEFERRED, - 'IMMEDIATE' => ForeignKey::IMMEDIATE, - 'NOT DEFERRED' => ForeignKey::NOT_DEFERRED, - ForeignKey::DEFERRED => ForeignKey::DEFERRED, - ForeignKey::IMMEDIATE => ForeignKey::IMMEDIATE, - ForeignKey::NOT_DEFERRED => ForeignKey::NOT_DEFERRED, - ]; - $normalized = strtoupper(str_replace('_', ' ', $deferrable)); - if (array_key_exists($normalized, $mapping)) { - return $mapping[$normalized]; - } - - throw new InvalidArgumentException('Unknown deferrable passed: ' . $deferrable); + return $this->mapAction($this->getUpdate()); } } diff --git a/src/Db/Table/Index.php b/src/Db/Table/Index.php index 264bdba54..f1ab782b4 100644 --- a/src/Db/Table/Index.php +++ b/src/Db/Table/Index.php @@ -8,6 +8,7 @@ namespace Migrations\Db\Table; +use Cake\Database\Schema\Index as DatabaseIndex; use RuntimeException; /** @@ -18,7 +19,7 @@ * @see \Migrations\BaseMigration::index() * @see \Migrations\Db\Table::addIndex() */ -class Index +class Index extends DatabaseIndex { /** * @var string @@ -36,44 +37,28 @@ class Index public const FULLTEXT = 'fulltext'; /** - * @var string[]|null - */ - protected ?array $columns = null; - - /** - * @var string - */ - protected string $type = self::INDEX; - - /** - * @var string|null - */ - protected ?string $name = null; - - /** - * @var int|array|null - */ - protected int|array|null $limit = null; - - /** - * @var string[]|null - */ - protected ?array $order = null; - - /** - * @var string[]|null - */ - protected ?array $includedColumns = null; - - /** - * @var bool - */ - protected bool $concurrent = false; - - /** - * @var string|null The where clause for partial indexes. + * Constructor + * + * @param string $name The name of the index. + * @param array $columns The columns to index. + * @param string $type The type of index, e.g. 'index', 'fulltext'. + * @param array|int|null $length The length of the index. + * @param array|null $order The sort order of the index columns. + * @param array|null $include The included columns for covering indexes. + * @param ?string $where The where clause for partial indexes. + * @param bool $concurrent Whether to create the index concurrently. */ - protected ?string $where = null; + public function __construct( + protected string $name = '', + protected array $columns = [], + protected string $type = self::INDEX, + protected array|int|null $length = null, + protected ?array $order = null, + protected ?array $include = null, + protected ?string $where = null, + protected bool $concurrent = false, + ) { + } /** * Sets the index columns. @@ -88,16 +73,6 @@ public function setColumns(string|array $columns) return $this; } - /** - * Gets the index columns. - * - * @return string[]|null - */ - public function getColumns(): ?array - { - return $this->columns; - } - /** * Sets the index type. * @@ -121,29 +96,6 @@ public function getType(): string return $this->type; } - /** - * Sets the index name. - * - * @param string $name Name - * @return $this - */ - public function setName(string $name) - { - $this->name = $name; - - return $this; - } - - /** - * Gets the index name. - * - * @return string|null - */ - public function getName(): ?string - { - return $this->name; - } - /** * Sets the index limit. * @@ -152,10 +104,11 @@ public function getName(): ?string * * @param int|array $limit limit value or array of limit value * @return $this + * @deprecated 5.0 Use setLength() instead. */ public function setLimit(int|array $limit) { - $this->limit = $limit; + $this->setLength($limit); return $this; } @@ -164,61 +117,11 @@ public function setLimit(int|array $limit) * Gets the index limit. * * @return int|array|null + * @deprecated 5.0 Use getLength() instead. */ public function getLimit(): int|array|null { - return $this->limit; - } - - /** - * Sets the index columns sort order. - * - * @param string[] $order column name sort order key value pair - * @return $this - */ - public function setOrder(array $order) - { - $this->order = $order; - - return $this; - } - - /** - * Gets the index columns sort order. - * - * @return string[]|null - */ - public function getOrder(): ?array - { - return $this->order; - } - - /** - * Sets the index included columns for a 'covering index'. - * - * In postgres and sqlserver, indexes can define additional non-key - * columns to build 'covering indexes'. This feature allows you to - * further optimize well-crafted queries that leverage specific - * indexes by reading all data from the index. - * - * @param string[] $includedColumns Columns - * @return $this - */ - public function setInclude(array $includedColumns) - { - $this->includedColumns = $includedColumns; - - return $this; - } - - /** - * Gets the index included columns. - * - * @return string[]|null - */ - public function getInclude(): ?array - { - return $this->includedColumns; + return $this->getLength(); } /** @@ -246,29 +149,6 @@ 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 object's methods. * diff --git a/src/Db/Table/Partition.php b/src/Db/Table/Partition.php new file mode 100644 index 000000000..12f8fe2a6 --- /dev/null +++ b/src/Db/Table/Partition.php @@ -0,0 +1,109 @@ + $options Additional options + */ + public function __construct( + protected string $type, + protected string|array|Literal $columns, + protected array $definitions = [], + protected ?int $count = null, + protected array $options = [], + ) { + } + + /** + * Get the partition type. + * + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * Get the columns or expression used for partitioning. + * + * @return string[]|\Migrations\Db\Literal + */ + public function getColumns(): array|Literal + { + if ($this->columns instanceof Literal) { + return $this->columns; + } + + return is_string($this->columns) ? [$this->columns] : $this->columns; + } + + /** + * Get the partition definitions. + * + * @return \Migrations\Db\Table\PartitionDefinition[] + */ + public function getDefinitions(): array + { + return $this->definitions; + } + + /** + * Get the partition count (for HASH/KEY types). + * + * @return int|null + */ + public function getCount(): ?int + { + return $this->count; + } + + /** + * Get additional options. + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Add a partition definition. + * + * @param \Migrations\Db\Table\PartitionDefinition $definition The partition definition + * @return $this + */ + public function addDefinition(PartitionDefinition $definition) + { + $this->definitions[] = $definition; + + return $this; + } +} diff --git a/src/Db/Table/PartitionDefinition.php b/src/Db/Table/PartitionDefinition.php new file mode 100644 index 000000000..192e305d4 --- /dev/null +++ b/src/Db/Table/PartitionDefinition.php @@ -0,0 +1,85 @@ +name; + } + + /** + * Get the boundary value. + * + * @return mixed + */ + public function getValue(): mixed + { + return $this->value; + } + + /** + * Get the tablespace. + * + * @return string|null + */ + public function getTablespace(): ?string + { + return $this->tablespace; + } + + /** + * Get the override table name (PostgreSQL only). + * + * @return string|null + */ + public function getTable(): ?string + { + return $this->table; + } + + /** + * Get the partition comment. + * + * @return string|null + */ + public function getComment(): ?string + { + return $this->comment; + } +} diff --git a/src/Db/Table/Table.php b/src/Db/Table/TableMetadata.php similarity index 72% rename from src/Db/Table/Table.php rename to src/Db/Table/TableMetadata.php index 847528f7a..09edd8500 100644 --- a/src/Db/Table/Table.php +++ b/src/Db/Table/TableMetadata.php @@ -12,9 +12,8 @@ /** * @internal - * @TODO rename this to `TableMetadata` having two classes with very similar names is confusing. */ -class Table +class TableMetadata { /** * @var string @@ -26,6 +25,11 @@ class Table */ protected array $options; + /** + * @var \Migrations\Db\Table\Partition|null + */ + protected ?Partition $partition = null; + /** * @param string $name The table name * @param array $options The creation options for this table @@ -86,4 +90,27 @@ public function setOptions(array $options) return $this; } + + /** + * Gets the partition configuration + * + * @return \Migrations\Db\Table\Partition|null + */ + public function getPartition(): ?Partition + { + return $this->partition; + } + + /** + * Sets the partition configuration + * + * @param \Migrations\Db\Table\Partition|null $partition The partition configuration + * @return $this + */ + public function setPartition(?Partition $partition) + { + $this->partition = $partition; + + return $this; + } } diff --git a/src/Migration/BuiltinBackend.php b/src/Migration/BuiltinBackend.php index d1b904edf..ab1118a31 100644 --- a/src/Migration/BuiltinBackend.php +++ b/src/Migration/BuiltinBackend.php @@ -150,9 +150,10 @@ public function seed(array $options = []): bool { $options['source'] ??= ConfigInterface::DEFAULT_SEED_FOLDER; $seed = $options['seed'] ?? null; + $force = $options['force'] ?? false; $manager = $this->getManager($options); - $manager->seed($seed); + $manager->seed($seed, $force); return true; } diff --git a/src/Migration/Environment.php b/src/Migration/Environment.php index ecde844c1..e4979cb01 100644 --- a/src/Migration/Environment.php +++ b/src/Migration/Environment.php @@ -14,7 +14,6 @@ use Migrations\Db\Adapter\AdapterInterface; use Migrations\MigrationInterface; use Migrations\SeedInterface; -use Migrations\Shim\MigrationAdapter; use RuntimeException; class Environment @@ -74,8 +73,6 @@ public function executeMigration(MigrationInterface $migration, string $directio $startTime = time(); - // Use an adapter shim to bridge between the new migrations - // engine and the Phinx compatible interface $adapter = $this->getAdapter(); $migration->setAdapter($adapter); @@ -95,32 +92,28 @@ public function executeMigration(MigrationInterface $migration, string $directio } if (!$fake) { - if ($migration instanceof MigrationAdapter) { - $migration->applyDirection($direction); - } else { - // Run the migration - if (method_exists($migration, MigrationInterface::CHANGE)) { - if ($direction === MigrationInterface::DOWN) { - // Create an instance of the RecordingAdapter so we can record all - // of the migration commands for reverse playback - - /** @var \Migrations\Db\Adapter\RecordingAdapter $recordAdapter */ - $recordAdapter = AdapterFactory::instance() - ->getWrapper('record', $adapter); - - // Wrap the adapter with a phinx shim to maintain contain - $migration->setAdapter($recordAdapter); - - $migration->{MigrationInterface::CHANGE}(); - $recordAdapter->executeInvertedCommands(); - - $migration->setAdapter($this->getAdapter()); - } else { - $migration->{MigrationInterface::CHANGE}(); - } + // Run the migration + if (method_exists($migration, MigrationInterface::CHANGE)) { + if ($direction === MigrationInterface::DOWN) { + // Create an instance of the RecordingAdapter so we can record all + // of the migration commands for reverse playback + + /** @var \Migrations\Db\Adapter\RecordingAdapter $recordAdapter */ + $recordAdapter = AdapterFactory::instance() + ->getWrapper('record', $adapter); + + // Wrap the adapter with a phinx shim to maintain contain + $migration->setAdapter($recordAdapter); + + $migration->{MigrationInterface::CHANGE}(); + $recordAdapter->executeInvertedCommands(); + + $migration->setAdapter($this->getAdapter()); } else { - $migration->{$direction}(); + $migration->{MigrationInterface::CHANGE}(); } + } else { + $migration->{$direction}(); } } @@ -157,6 +150,12 @@ public function executeSeed(SeedInterface $seed): void // Run the seeder $seed->{SeedInterface::RUN}(); + // Record the seed execution (skip for idempotent seeds) + if (!$seed->isIdempotent()) { + $executedTime = date('Y-m-d H:i:s'); + $adapter->seedExecuted($seed, $executedTime); + } + // commit the transaction if the adapter supports it if ($atomic) { $adapter->commitTransaction(); diff --git a/src/Migration/Manager.php b/src/Migration/Manager.php index 226bfb235..ebf7a3393 100644 --- a/src/Migration/Manager.php +++ b/src/Migration/Manager.php @@ -10,17 +10,14 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; +use Cake\Core\Configure; use DateTime; use Exception; use InvalidArgumentException; use Migrations\Config\ConfigInterface; use Migrations\MigrationInterface; use Migrations\SeedInterface; -use Migrations\Shim\MigrationAdapter; -use Migrations\Shim\SeedAdapter; use Migrations\Util\Util; -use Phinx\Migration\MigrationInterface as PhinxMigrationInterface; -use Phinx\Seed\SeedInterface as PhinxSeedInterface; use Psr\Container\ContainerInterface; use RuntimeException; @@ -210,6 +207,74 @@ public function isMigrated(int $version): bool return isset($versions[$version]); } + /** + * Check if a seed has been executed. + * + * @param \Migrations\SeedInterface $seed Seed to check + * @return bool + */ + public function isSeedExecuted(SeedInterface $seed): bool + { + $adapter = $this->getEnvironment()->getAdapter(); + + // Ensure seed schema table exists + if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) { + return false; + } + + $seedLog = $adapter->getSeedLog(); + + $plugin = null; + $className = get_class($seed); + + if (str_contains($className, '\\')) { + $parts = explode('\\', $className); + $appNamespace = Configure::read('App.namespace', 'App'); + if (count($parts) > 1 && $parts[0] !== $appNamespace) { + $plugin = $parts[0]; + } + } + + $seedName = $seed->getName(); + + foreach ($seedLog as $entry) { + if ($entry['seed_name'] === $seedName && $entry['plugin'] === $plugin) { + return true; + } + } + + return false; + } + + /** + * Get dependencies of a seed that have not been executed yet. + * + * @param \Migrations\SeedInterface $seed Seed to check dependencies for + * @return array<\Migrations\SeedInterface> + */ + public function getSeedDependenciesNotExecuted(SeedInterface $seed): array + { + $dependencies = $seed->getDependencies(); + if (!$dependencies) { + return []; + } + + $seeds = $this->getSeeds(); + $notExecuted = []; + + foreach ($dependencies as $depName) { + $normalizedName = $this->normalizeSeedName($depName, $seeds); + if ($normalizedName !== null && isset($seeds[$normalizedName])) { + $depSeed = $seeds[$normalizedName]; + if (!$this->isSeedExecuted($depSeed)) { + $notExecuted[] = $depSeed; + } + } + } + + return $notExecuted; + } + /** * Marks migration with version number $version migrated * @@ -230,15 +295,28 @@ public function markMigrated(int $version, string $path): bool } $migrationFile = $migrationFile[0]; - /** @var class-string<\Phinx\Migration\MigrationInterface|\Migrations\MigrationInterface> $className */ $className = $this->getMigrationClassName($migrationFile); - require_once $migrationFile; - if (is_subclass_of($className, PhinxMigrationInterface::class)) { - $migration = new MigrationAdapter($className, $version); + // For anonymous classes, we need to use require instead of require_once + $migrationInstance = null; + if (!class_exists($className)) { + $migrationInstance = require $migrationFile; } else { + require_once $migrationFile; + } + + // Check if the file returns an anonymous class instance + if (is_object($migrationInstance) && $migrationInstance instanceof MigrationInterface) { + $migration = $migrationInstance; + $migration->setVersion($version); + } elseif (class_exists($className)) { $migration = new $className($version); + } else { + throw new RuntimeException( + sprintf('Could not find class `%s` in file `%s` and file did not return a migration instance', $className, $migrationFile), + ); } + /** @var \Migrations\MigrationInterface $migration */ $config = $this->getConfig(); $migration->setConfig($config); @@ -254,7 +332,7 @@ public function markMigrated(int $version, string $path): bool * Resolves a migration class name based on $path * * @param string $path Path to the migration file of which we want the class name - * @return string Migration class name + * @return class-string<\Migrations\MigrationInterface> Migration class name */ protected function getMigrationClassName(string $path): string { @@ -266,6 +344,7 @@ protected function getMigrationClassName(string $path): string $class = substr($class, 0, strpos($class, '.')); } + /** @var class-string<\Migrations\MigrationInterface> */ return $class; } @@ -382,7 +461,7 @@ public function migrate(?int $version = null, bool $fake = false, ?int $count = if ($version === null) { $version = max(array_merge($versions, array_keys($migrations))); } else { - if ($version != 0 && !isset($migrations[$version])) { + if ($version !== 0 && !isset($migrations[$version])) { $this->getIo()->out(sprintf( 'warning %s is not a valid version', $version, @@ -460,9 +539,11 @@ public function executeMigration(MigrationInterface $migration, string $directio * Execute a seeder against the specified environment. * * @param \Migrations\SeedInterface $seed Seed + * @param bool $force Force re-execution even if seed has already been executed + * @param bool $fake Record seed as executed without actually running it * @return void */ - public function executeSeed(SeedInterface $seed): void + public function executeSeed(SeedInterface $seed, bool $force = false, bool $fake = false): void { $this->getIo()->out(''); @@ -473,6 +554,50 @@ public function executeSeed(SeedInterface $seed): void return; } + // Check if seed has already been executed (skip for idempotent seeds) + if (!$force && !$seed->isIdempotent() && $this->isSeedExecuted($seed)) { + $this->printSeedStatus($seed, 'already executed'); + + return; + } + + // Ensure seed schema table exists + $adapter = $this->getEnvironment()->getAdapter(); + if (!$adapter->hasTable($adapter->getSeedSchemaTableName())) { + $adapter->createSeedSchemaTable(); + } + + if ($fake) { + // Idempotent seeds are not tracked, so faking doesn't apply + if ($seed->isIdempotent()) { + $this->printSeedStatus($seed, 'skipped (idempotent)'); + + return; + } + + // Record seed as executed without running it + $this->printSeedStatus($seed, 'faking'); + + $executedTime = date('Y-m-d H:i:s'); + $adapter->seedExecuted($seed, $executedTime); + + $this->printSeedStatus($seed, 'faked'); + + return; + } + + // Auto-execute missing dependencies + $missingDeps = $this->getSeedDependenciesNotExecuted($seed); + if (!empty($missingDeps)) { + foreach ($missingDeps as $depSeed) { + $this->getIo()->verbose(sprintf( + ' Auto-executing dependency: %s', + $depSeed->getName(), + )); + $this->executeSeed($depSeed, $force, $fake); + } + } + $this->printSeedStatus($seed, 'seeding'); // Execute the seeder and log the time elapsed. @@ -515,7 +640,7 @@ protected function printMigrationStatus(MigrationInterface $migration, string $s protected function printSeedStatus(SeedInterface $seed, string $status, ?string $duration = null): void { $this->printStatusOutput( - $seed->getName(), + Util::getSeedDisplayName($seed->getName()) . ' seed', $status, $duration, ); @@ -630,7 +755,7 @@ public function rollback(int|string|null $target = null, bool $force = false, bo // Check we have at least 1 migration to revert $executedVersionCreationTimes = array_keys($executedVersions); - if (!$executedVersionCreationTimes || $target == end($executedVersionCreationTimes)) { + if (!$executedVersionCreationTimes || $target === end($executedVersionCreationTimes)) { $io->out('No migrations to rollback'); return; @@ -670,7 +795,7 @@ public function rollback(int|string|null $target = null, bool $force = false, bo } } - if ($executedArray['breakpoint'] != 0 && !$force) { + if ((int)$executedArray['breakpoint'] !== 0 && !$force) { $io->out('Breakpoint reached. Further rollbacks inhibited.'); break; } @@ -688,10 +813,12 @@ public function rollback(int|string|null $target = null, bool $force = false, bo * Run database seeders against an environment. * * @param string|null $seed Seeder + * @param bool $force Force re-execution even if seed has already been executed + * @param bool $fake Record seed as executed without actually running it * @throws \InvalidArgumentException * @return void */ - public function seed(?string $seed = null): void + public function seed(?string $seed = null, bool $force = false, bool $fake = false): void { $seeds = $this->getSeeds(); @@ -699,16 +826,14 @@ public function seed(?string $seed = null): void // run all seeders foreach ($seeds as $seeder) { if (array_key_exists($seeder->getName(), $seeds)) { - $this->executeSeed($seeder); + $this->executeSeed($seeder, $force, $fake); } } } else { // run only one seeder - if (array_key_exists($seed . 'Seed', $seeds)) { - $seed = $seed . 'Seed'; - $this->executeSeed($seeds[$seed]); - } elseif (array_key_exists($seed, $seeds)) { - $this->executeSeed($seeds[$seed]); + $normalizedName = $this->normalizeSeedName($seed, $seeds); + if ($normalizedName !== null) { + $this->executeSeed($seeds[$normalizedName], $force, $fake); } else { throw new InvalidArgumentException(sprintf('The seed `%s` does not exist', $seed)); } @@ -858,23 +983,35 @@ function ($phpFile) { // load the migration file $orig_display_errors_setting = ini_get('display_errors'); ini_set('display_errors', 'On'); - /** @noinspection PhpIncludeInspection */ - require_once $filePath; - ini_set('display_errors', $orig_display_errors_setting); + + // For anonymous classes, we need to use require instead of require_once + // to get the returned instance + $migrationInstance = null; if (!class_exists($class)) { + $migrationInstance = require $filePath; + } else { + require_once $filePath; + } + + ini_set('display_errors', $orig_display_errors_setting); + + // Check if the file returns an anonymous class instance + if (is_object($migrationInstance) && $migrationInstance instanceof MigrationInterface) { + $io->verbose("Using anonymous class from $filePath."); + $migration = $migrationInstance; + $migration->setVersion($version); + } elseif (class_exists($class)) { + // Fall back to traditional class-based migration + $io->verbose("Constructing $class."); + $migration = new $class($version); + } else { throw new InvalidArgumentException(sprintf( - 'Could not find class `%s` in file `%s`', + 'Could not find class `%s` in file `%s` and file did not return a migration instance', $class, $filePath, )); } - $io->verbose("Constructing $class."); - if (is_subclass_of($class, PhinxMigrationInterface::class)) { - $migration = new MigrationAdapter($class, $version); - } else { - $migration = new $class($version); - } /** @var \Migrations\MigrationInterface $migration */ $config = $this->getConfig(); $migration->setConfig($config); @@ -916,6 +1053,28 @@ public function setSeeds(array $seeds) return $this; } + /** + * Normalize a seed name by trying with and without the 'Seed' suffix. + * + * @param string $name Seed name to normalize + * @param array $seeds Seeds array to search in + * @return string|null The normalized seed name, or null if not found + */ + public function normalizeSeedName(string $name, array $seeds): ?string + { + // Try with 'Seed' suffix first + if (array_key_exists($name . 'Seed', $seeds)) { + return $name . 'Seed'; + } + + // Try exact name + if (array_key_exists($name, $seeds)) { + return $name; + } + + return null; + } + /** * Get seed dependencies instances from seed dependency array * @@ -928,11 +1087,9 @@ protected function getSeedDependenciesInstances(SeedInterface $seed): array $dependencies = $seed->getDependencies(); if ($dependencies && $this->seeds) { foreach ($dependencies as $dependency) { - foreach ($this->seeds as $seed) { - $name = $seed->getName(); - if ($name === $dependency) { - $dependenciesInstances[$name] = $seed; - } + $normalizedName = $this->normalizeSeedName($dependency, $this->seeds); + if ($normalizedName !== null) { + $dependenciesInstances[$normalizedName] = $this->seeds[$normalizedName]; } } } @@ -944,18 +1101,46 @@ protected function getSeedDependenciesInstances(SeedInterface $seed): array * Order seeds by dependencies * * @param \Migrations\SeedInterface[] $seeds Seeds + * @param array $visiting Seeds currently being visited (for cycle detection) + * @param array $visited Seeds that have been fully processed * @return \Migrations\SeedInterface[] + * @throws \RuntimeException When a circular dependency is detected */ - protected function orderSeedsByDependencies(array $seeds): array + protected function orderSeedsByDependencies(array $seeds, array $visiting = [], array &$visited = []): array { $orderedSeeds = []; foreach ($seeds as $seed) { $name = $seed->getName(); - $orderedSeeds[$name] = $seed; + + // Skip if already fully processed + if (isset($visited[$name])) { + continue; + } + + // Check for circular dependency + if (isset($visiting[$name])) { + $cycle = array_keys($visiting); + $cycle[] = $name; + throw new RuntimeException( + 'Circular dependency detected in seeds: ' . implode(' -> ', $cycle), + ); + } + + // Mark as currently visiting + $visiting[$name] = true; + $dependencies = $this->getSeedDependenciesInstances($seed); if ($dependencies) { - $orderedSeeds = array_merge($this->orderSeedsByDependencies($dependencies), $orderedSeeds); + $orderedSeeds = array_merge( + $this->orderSeedsByDependencies($dependencies, $visiting, $visited), + $orderedSeeds, + ); } + + // Mark as fully visited and add to result + $visited[$name] = true; + unset($visiting[$name]); + $orderedSeeds[$name] = $seed; } return $orderedSeeds; @@ -983,32 +1168,42 @@ public function getSeeds(): array foreach ($phpFiles as $filePath) { if (Util::isValidSeedFileName(basename($filePath))) { // convert the filename to a class name + /** @var class-string<\Migrations\SeedInterface> $class */ $class = pathinfo($filePath, PATHINFO_FILENAME); $fileNames[$class] = basename($filePath); // load the seed file - /** @noinspection PhpIncludeInspection */ - require_once $filePath; + // For anonymous classes, we need to use require instead of require_once + // to get the returned instance + $seedInstance = null; if (!class_exists($class)) { + $seedInstance = require $filePath; + } else { + require_once $filePath; + } + + // Check if the file returns an anonymous class instance + if (is_object($seedInstance) && $seedInstance instanceof SeedInterface) { + $io->verbose("Using anonymous class from $filePath."); + $seed = $seedInstance; + } elseif (class_exists($class)) { + // Fall back to traditional class-based seed + $io->verbose("Instantiating $class."); + // instantiate it + /** @var \Migrations\SeedInterface $seed */ + if (isset($this->container)) { + $seed = $this->container->get($class); + } else { + $seed = new $class(); + } + } else { throw new InvalidArgumentException(sprintf( - 'Could not find class `%s` in file `%s`', + 'Could not find class `%s` in file `%s` and file did not return a seed instance', $class, $filePath, )); } - // instantiate it - /** @var \Phinx\Seed\AbstractSeed|\Migrations\SeedInterface $seed */ - if (isset($this->container)) { - $seed = $this->container->get($class); - } else { - $seed = new $class(); - } - // Shim phinx seeds so that the rest of migrations - // can be isolated from phinx. - if ($seed instanceof PhinxSeedInterface) { - $seed = new SeedAdapter($seed); - } /** @var \Migrations\SeedInterface $seed */ $seed->setIo($io); $seed->setConfig($config); @@ -1095,7 +1290,7 @@ protected function markBreakpoint(?int $version, int $mark): void } $io = $this->getIo(); - if ($version != 0 && (!isset($versions[$version]) || !isset($migrations[$version]))) { + if ($version !== 0 && (!isset($versions[$version]) || !isset($migrations[$version]))) { $io->out(sprintf( 'warning %s is not a valid version', $version, @@ -1109,12 +1304,12 @@ protected function markBreakpoint(?int $version, int $mark): void $env->getAdapter()->toggleBreakpoint($migrations[$version]); break; case self::BREAKPOINT_SET: - if ($versions[$version]['breakpoint'] == 0) { + if ((int)$versions[$version]['breakpoint'] === 0) { $env->getAdapter()->setBreakpoint($migrations[$version]); } break; case self::BREAKPOINT_UNSET: - if ($versions[$version]['breakpoint'] == 1) { + if ((int)$versions[$version]['breakpoint'] === 1) { $env->getAdapter()->unsetBreakpoint($migrations[$version]); } break; @@ -1185,9 +1380,23 @@ public function resetSeeds(): void } /** - * Cleanup missing migrations from the phinxlog table + * Gets the schema table name being used for migration tracking. + * + * Returns the actual table name based on current configuration: + * - 'cake_migrations' for unified mode + * - 'phinxlog' or '{plugin}_phinxlog' for legacy mode + * + * @return string The migration tracking table name + */ + public function getSchemaTableName(): string + { + return $this->getEnvironment()->getAdapter()->getSchemaTableName(); + } + + /** + * Cleanup missing migrations from the migration tracking table. * - * Removes entries from the phinxlog table for migrations that no longer exist + * Removes entries from the migrations table for migrations that no longer exist * in the migrations directory (marked as MISSING in status output). * * @return int The number of missing migrations removed @@ -1199,7 +1408,7 @@ public function cleanupMissingMigrations(): int $versions = $env->getVersionLog(); $adapter = $env->getAdapter(); - // Find missing migrations (those in phinxlog but not in filesystem) + // Find missing migrations (those in migration table but not in filesystem) $missingVersions = []; foreach ($versions as $versionId => $versionInfo) { if (!isset($defaultMigrations[$versionId])) { @@ -1211,18 +1420,8 @@ public function cleanupMissingMigrations(): int return 0; } - // Remove missing migrations from phinxlog - $adapter->beginTransaction(); - try { - $delete = $adapter->getDeleteBuilder() - ->from($env->getSchemaTableName()) - ->where(['version IN' => $missingVersions]); - $delete->execute(); - $adapter->commitTransaction(); - } catch (Exception $e) { - $adapter->rollbackTransaction(); - throw $e; - } + // Remove missing migrations from migrations table + $adapter->cleanupMissing($missingVersions); return count($missingVersions); } diff --git a/src/Migration/ManagerFactory.php b/src/Migration/ManagerFactory.php index 3bede391b..362a06305 100644 --- a/src/Migration/ManagerFactory.php +++ b/src/Migration/ManagerFactory.php @@ -85,7 +85,6 @@ public function createConfig(): ConfigInterface // Get the phinxlog table name. Plugins have separate migration history. // The names and separate table history is something we could change in the future. $table = Util::tableName($plugin); - $templatePath = dirname(__DIR__) . DS . 'templates' . DS; $connectionName = (string)$this->getOption('connection'); if (str_contains($connectionName, '://')) { @@ -112,7 +111,9 @@ public function createConfig(): ConfigInterface 'connection' => $connectionName, 'database' => $connectionConfig['database'], 'migration_table' => $table, + 'seed_table' => Configure::read('Migrations.seed_table', 'cake_seeds'), 'dryrun' => $this->getOption('dry-run'), + 'plugin' => $plugin, ]; $configData = [ @@ -120,13 +121,9 @@ public function createConfig(): ConfigInterface 'migrations' => $dir, 'seeds' => $dir, ], - 'templates' => [ - 'file' => $templatePath . 'Phinx/create.php.template', - ], - 'migration_base_class' => 'Migrations\AbstractMigration', 'environment' => $adapterConfig, 'plugin' => $plugin, - 'source' => (string)$this->getOption('source'), + 'source' => $folder, 'feature_flags' => [ 'unsigned_primary_keys' => Configure::read('Migrations.unsigned_primary_keys'), 'column_null_default' => Configure::read('Migrations.column_null_default'), diff --git a/src/Migration/PhinxBackend.php b/src/Migration/PhinxBackend.php deleted file mode 100644 index af96cbb38..000000000 --- a/src/Migration/PhinxBackend.php +++ /dev/null @@ -1,396 +0,0 @@ - - */ - protected array $default = []; - - /** - * Current command being run. - * Useful if some logic needs to be applied in the ConfigurationTrait depending - * on the command - * - * @var string - */ - protected string $command; - - /** - * Stub input to feed the manager class since we might not have an input ready when we get the Manager using - * the `getManager()` method - * - * @var \Symfony\Component\Console\Input\ArrayInput - */ - protected ArrayInput $stubInput; - - /** - * Constructor - * - * @param array $default Default option to be used when calling a method. - * Available options are : - * - `connection` The datasource connection to use - * - `source` The folder where migrations are in - * - `plugin` The plugin containing the migrations - */ - public function __construct(array $default = []) - { - $this->output = new NullOutput(); - $this->stubInput = new ArrayInput([]); - - if ($default) { - $this->default = $default; - } - } - - /** - * Sets the command - * - * @param string $command Command name to store. - * @return $this - */ - public function setCommand(string $command) - { - $this->command = $command; - - return $this; - } - - /** - * Sets the input object that should be used for the command class. This object - * is used to inspect the extra options that are needed for CakePHP apps. - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @return void - */ - public function setInput(InputInterface $input): void - { - $this->input = $input; - } - - /** - * Gets the command - * - * @return string Command name - */ - public function getCommand(): string - { - return $this->command; - } - - /** - * {@inheritDoc} - */ - public function status(array $options = []): array - { - $input = $this->getInput('Status', [], $options); - $params = ['default', $input->getOption('format')]; - - return $this->run('printStatus', $params, $input); - } - - /** - * {@inheritDoc} - */ - public function migrate(array $options = []): bool - { - $this->setCommand('migrate'); - $input = $this->getInput('Migrate', [], $options); - $method = 'migrate'; - $params = ['default', $input->getOption('target')]; - - if ($input->getOption('date')) { - $method = 'migrateToDateTime'; - $params[1] = new DateTime($input->getOption('date')); - } - - $this->run($method, $params, $input); - - return true; - } - - /** - * {@inheritDoc} - */ - public function rollback(array $options = []): bool - { - $this->setCommand('rollback'); - $input = $this->getInput('Rollback', [], $options); - $method = 'rollback'; - $params = ['default', $input->getOption('target')]; - - if ($input->getOption('date')) { - $method = 'rollbackToDateTime'; - $params[1] = new DateTime($input->getOption('date')); - } - - $this->run($method, $params, $input); - - return true; - } - - /** - * {@inheritDoc} - */ - public function markMigrated(int|string|null $version = null, array $options = []): bool - { - $this->setCommand('mark_migrated'); - - if ( - isset($options['target']) && - isset($options['exclude']) && - isset($options['only']) - ) { - $exceptionMessage = 'You should use `exclude` OR `only` (not both) along with a `target` argument'; - throw new InvalidArgumentException($exceptionMessage); - } - - $input = $this->getInput('MarkMigrated', ['version' => $version], $options); - $this->setInput($input); - - // This will need to vary based on the config option. - $migrationPaths = $this->getConfig()->getMigrationPaths(); - $config = $this->getConfig(true); - $params = [ - array_pop($migrationPaths), - $this->getManager($config)->getVersionsToMark($input), - $this->output, - ]; - - $this->run('markVersionsAsMigrated', $params, $input); - - return true; - } - - /** - * {@inheritDoc} - */ - public function seed(array $options = []): bool - { - $this->setCommand('seed'); - $input = $this->getInput('Seed', [], $options); - - $seed = $input->getOption('seed'); - if (!$seed) { - $seed = null; - } - - $params = ['default', $seed]; - $this->run('seed', $params, $input); - - return true; - } - - /** - * {@inheritDoc} - */ - protected function run(string $method, array $params, InputInterface $input): mixed - { - // This will need to vary based on the backend configuration - if ($this->configuration instanceof Config) { - $migrationPaths = $this->getConfig()->getMigrationPaths(); - $migrationPath = array_pop($migrationPaths); - $seedPaths = $this->getConfig()->getSeedPaths(); - $seedPath = array_pop($seedPaths); - } - - $pdo = null; - if ($this->manager instanceof Manager) { - $pdo = $this->manager->getEnvironment('default') - ->getAdapter() - ->getConnection(); - } - - $this->setInput($input); - $newConfig = $this->getConfig(true); - $manager = $this->getManager($newConfig); - $manager->setInput($input); - - // Why is this being done? Is this something we can eliminate in the new code path? - if ($pdo !== null) { - /** @var \Phinx\Db\Adapter\PdoAdapter|\Migrations\CakeAdapter $adapter */ - $adapter = $this->manager->getEnvironment('default')->getAdapter(); - while ($adapter instanceof WrapperInterface) { - /** @var \Phinx\Db\Adapter\PdoAdapter|\Migrations\CakeAdapter $adapter */ - $adapter = $adapter->getAdapter(); - } - $adapter->setConnection($pdo); - } - - $newMigrationPaths = $newConfig->getMigrationPaths(); - if (isset($migrationPath) && array_pop($newMigrationPaths) !== $migrationPath) { - $manager->resetMigrations(); - } - $newSeedPaths = $newConfig->getSeedPaths(); - if (isset($seedPath) && array_pop($newSeedPaths) !== $seedPath) { - $manager->resetSeeds(); - } - - /** @var callable $callable */ - $callable = [$manager, $method]; - - return call_user_func_array($callable, $params); - } - - /** - * Returns an instance of CakeManager - * - * @param \Phinx\Config\ConfigInterface|null $config ConfigInterface the Manager needs to run - * @return \Migrations\CakeManager Instance of CakeManager - */ - public function getManager(?ConfigInterface $config = null): CakeManager - { - if (!($this->manager instanceof CakeManager)) { - if (!($config instanceof ConfigInterface)) { - throw new RuntimeException( - 'You need to pass a ConfigInterface object for your first getManager() call', - ); - } - - $input = $this->input ?: $this->stubInput; - $this->manager = new CakeManager($config, $input, $this->output); - } elseif ($config !== null) { - $defaultEnvironment = $config->getEnvironment('default'); - try { - $environment = $this->manager->getEnvironment('default'); - $oldConfig = $environment->getOptions(); - unset($oldConfig['connection']); - if ($oldConfig === $defaultEnvironment) { - $defaultEnvironment['connection'] = $environment - ->getAdapter() - ->getConnection(); - } - } catch (InvalidArgumentException $e) { - } - $config['environments'] = ['default' => $defaultEnvironment]; - $this->manager->setEnvironments([]); - $this->manager->setConfig($config); - } - - $this->setAdapter(); - - return $this->manager; - } - - /** - * Sets the adapter the manager is going to need to operate on the DB - * This will make sure the adapter instance is a \Migrations\CakeAdapter instance - * - * @return void - */ - public function setAdapter(): void - { - if ($this->input === null) { - return; - } - - $connectionName = $this->input()->getOption('connection') ?: 'default'; - assert(is_string($connectionName), 'Connection name should be a string'); - $connection = ConnectionManager::get($connectionName); - assert($connection instanceof Connection, 'Connection should be an instance of Cake\Database\Connection'); - - $env = $this->manager->getEnvironment('default'); - $adapter = $env->getAdapter(); - if (!$adapter instanceof CakeAdapter) { - $env->setAdapter(new CakeAdapter($adapter, $connection)); - } - } - - /** - * Get the input needed for each commands to be run - * - * @param string $command Command name for which we need the InputInterface - * @param array $arguments Simple key/values array representing the command arguments - * to pass to the InputInterface - * @param array $options Simple key/values array representing the command options - * to pass to the InputInterface - * @return \Symfony\Component\Console\Input\InputInterface InputInterface needed for the - * Manager to properly run - */ - public function getInput(string $command, array $arguments, array $options): InputInterface - { - $className = 'Migrations\Command\Phinx\\' . $command; - $options = $arguments + $this->prepareOptions($options); - /** @var \Symfony\Component\Console\Command\Command $command */ - $command = new $className(); - $definition = $command->getDefinition(); - - return new ArrayInput($options, $definition); - } - - /** - * Prepares the option to pass on to the InputInterface - * - * @param array $options Simple key-values array to pass to the InputInterface - * @return array Prepared $options - */ - protected function prepareOptions(array $options = []): array - { - $options += $this->default; - if (!$options) { - return $options; - } - - foreach ($options as $name => $value) { - $options['--' . $name] = $value; - unset($options[$name]); - } - - return $options; - } -} diff --git a/src/Migrations.php b/src/Migrations.php index b889667c3..b61bf07cf 100644 --- a/src/Migrations.php +++ b/src/Migrations.php @@ -13,19 +13,8 @@ */ namespace Migrations; -use Cake\Core\Configure; -use Cake\Database\Connection; -use Cake\Datasource\ConnectionManager; -use InvalidArgumentException; use Migrations\Migration\BackendInterface; use Migrations\Migration\BuiltinBackend; -use Migrations\Migration\PhinxBackend; -use Phinx\Config\ConfigInterface; -use RuntimeException; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\NullOutput; -use Symfony\Component\Console\Output\OutputInterface; /** * The Migrations class is responsible for handling migrations command @@ -33,23 +22,6 @@ */ class Migrations { - use ConfigurationTrait; - - /** - * The OutputInterface. - * Should be a \Symfony\Component\Console\Output\NullOutput instance - * - * @var \Symfony\Component\Console\Output\OutputInterface - */ - protected OutputInterface $output; - - /** - * CakeManager instance - * - * @var \Migrations\CakeManager|null - */ - protected ?CakeManager $manager = null; - /** * Default options to use * @@ -66,14 +38,6 @@ class Migrations */ protected string $command = ''; - /** - * Stub input to feed the manager class since we might not have an input ready when we get the Manager using - * the `getManager()` method - * - * @var \Symfony\Component\Console\Input\ArrayInput - */ - protected ArrayInput $stubInput; - /** * Constructor * @@ -85,39 +49,11 @@ class Migrations */ public function __construct(array $default = []) { - $this->output = new NullOutput(); - $this->stubInput = new ArrayInput([]); - if ($default) { $this->default = $default; } } - /** - * Sets the command - * - * @param string $command Command name to store. - * @return $this - */ - public function setCommand(string $command) - { - $this->command = $command; - - return $this; - } - - /** - * Sets the input object that should be used for the command class. This object - * is used to inspect the extra options that are needed for CakePHP apps. - * - * @param \Symfony\Component\Console\Input\InputInterface $input the input object - * @return void - */ - public function setInput(InputInterface $input): void - { - $this->input = $input; - } - /** * Gets the command * @@ -129,21 +65,13 @@ public function getCommand(): string } /** - * Get the Migrations interface backend based on configuration data. + * Get the Migrations interface backend. * * @return \Migrations\Migration\BackendInterface */ protected function getBackend(): BackendInterface { - $backend = (string)(Configure::read('Migrations.backend') ?? 'builtin'); - if ($backend === 'builtin') { - return new BuiltinBackend($this->default); - } - if ($backend === 'phinx') { - return new PhinxBackend($this->default); - } - - throw new RuntimeException("Unknown `Migrations.backend` of `{$backend}`"); + return new BuiltinBackend($this->default); } /** @@ -244,111 +172,4 @@ public function seed(array $options = []): bool return $backend->seed($options); } - - /** - * Returns an instance of CakeManager - * - * @param \Phinx\Config\ConfigInterface|null $config ConfigInterface the Manager needs to run - * @return \Migrations\CakeManager Instance of CakeManager - */ - public function getManager(?ConfigInterface $config = null): CakeManager - { - if (!($this->manager instanceof CakeManager)) { - if (!($config instanceof ConfigInterface)) { - throw new RuntimeException( - 'You need to pass a ConfigInterface object for your first getManager() call', - ); - } - - $input = $this->input ?: $this->stubInput; - $this->manager = new CakeManager($config, $input, $this->output); - } elseif ($config !== null) { - $defaultEnvironment = $config->getEnvironment('default'); - try { - $environment = $this->manager->getEnvironment('default'); - $oldConfig = $environment->getOptions(); - unset($oldConfig['connection']); - if ($oldConfig === $defaultEnvironment) { - $defaultEnvironment['connection'] = $environment - ->getAdapter() - ->getConnection(); - } - } catch (InvalidArgumentException $e) { - } - $config['environments'] = ['default' => $defaultEnvironment]; - $this->manager->setEnvironments([]); - $this->manager->setConfig($config); - } - - $this->setAdapter(); - - return $this->manager; - } - - /** - * Sets the adapter the manager is going to need to operate on the DB - * This will make sure the adapter instance is a \Migrations\CakeAdapter instance - * - * @return void - */ - public function setAdapter(): void - { - if ($this->input === null) { - return; - } - - $connectionName = $this->input()->getOption('connection') ?: 'default'; - assert(is_string($connectionName), 'Connection name must be a string'); - $connection = ConnectionManager::get($connectionName); - assert($connection instanceof Connection, 'Connection must be an instance of \Cake\Database\Connection'); - - $env = $this->manager->getEnvironment('default'); - $adapter = $env->getAdapter(); - if (!$adapter instanceof CakeAdapter) { - $env->setAdapter(new CakeAdapter($adapter, $connection)); - } - } - - /** - * Get the input needed for each commands to be run - * - * @param string $command Command name for which we need the InputInterface - * @param array $arguments Simple key/values array representing the command arguments - * to pass to the InputInterface - * @param array $options Simple key/values array representing the command options - * to pass to the InputInterface - * @return \Symfony\Component\Console\Input\InputInterface InputInterface needed for the - * Manager to properly run - */ - public function getInput(string $command, array $arguments, array $options): InputInterface - { - $className = 'Migrations\Command\Phinx\\' . $command; - $options = $arguments + $this->prepareOptions($options); - /** @var \Symfony\Component\Console\Command\Command $command */ - $command = new $className(); - $definition = $command->getDefinition(); - - return new ArrayInput($options, $definition); - } - - /** - * Prepares the option to pass on to the InputInterface - * - * @param array $options Simple key-values array to pass to the InputInterface - * @return array Prepared $options - */ - protected function prepareOptions(array $options = []): array - { - $options += $this->default; - if (!$options) { - return $options; - } - - foreach ($options as $name => $value) { - $options['--' . $name] = $value; - unset($options[$name]); - } - - return $options; - } } diff --git a/src/MigrationsDispatcher.php b/src/MigrationsDispatcher.php deleted file mode 100644 index d1c9e67c4..000000000 --- a/src/MigrationsDispatcher.php +++ /dev/null @@ -1,62 +0,0 @@ - - * @phpstan-return array|class-string<\Migrations\Command\Phinx\BaseCommand>> - */ - public static function getCommands(): array - { - return [ - 'Create' => Phinx\Create::class, - 'Dump' => Phinx\Dump::class, - 'MarkMigrated' => Phinx\MarkMigrated::class, - 'Migrate' => Phinx\Migrate::class, - 'Rollback' => Phinx\Rollback::class, - 'Seed' => Phinx\Seed::class, - 'Status' => Phinx\Status::class, - 'CacheBuild' => Phinx\CacheBuild::class, - 'CacheClear' => Phinx\CacheClear::class, - ]; - } - - /** - * Initialize the Phinx console application. - * - * @param string $version The Application Version - */ - public function __construct(string $version) - { - parent::__construct('Migrations plugin, based on Phinx by Rob Morgan.', $version); - // Update this to use the methods - foreach ($this->getCommands() as $value) { - $this->add(new $value()); - } - $this->setCatchExceptions(false); - } -} diff --git a/src/MigrationsPlugin.php b/src/MigrationsPlugin.php index e01cb4d99..b627f1cd4 100644 --- a/src/MigrationsPlugin.php +++ b/src/MigrationsPlugin.php @@ -26,19 +26,13 @@ use Migrations\Command\EntryCommand; use Migrations\Command\MarkMigratedCommand; use Migrations\Command\MigrateCommand; -use Migrations\Command\MigrationsCacheBuildCommand; -use Migrations\Command\MigrationsCacheClearCommand; -use Migrations\Command\MigrationsCommand; -use Migrations\Command\MigrationsCreateCommand; -use Migrations\Command\MigrationsDumpCommand; -use Migrations\Command\MigrationsMarkMigratedCommand; -use Migrations\Command\MigrationsMigrateCommand; -use Migrations\Command\MigrationsRollbackCommand; -use Migrations\Command\MigrationsSeedCommand; -use Migrations\Command\MigrationsStatusCommand; use Migrations\Command\RollbackCommand; use Migrations\Command\SeedCommand; +use Migrations\Command\SeedResetCommand; +use Migrations\Command\SeedsEntryCommand; +use Migrations\Command\SeedStatusCommand; use Migrations\Command\StatusCommand; +use Migrations\Command\UpgradeCommand; /** * Plugin class for migrations @@ -55,22 +49,6 @@ class MigrationsPlugin extends BasePlugin */ protected bool $routesEnabled = false; - /** - * @var array> - */ - protected array $migrationCommandsList = [ - MigrationsCommand::class, - MigrationsCreateCommand::class, - MigrationsDumpCommand::class, - MigrationsMarkMigratedCommand::class, - MigrationsMigrateCommand::class, - MigrationsCacheBuildCommand::class, - MigrationsCacheClearCommand::class, - MigrationsRollbackCommand::class, - MigrationsSeedCommand::class, - MigrationsStatusCommand::class, - ]; - /** * Initialize configuration with defaults. * @@ -80,10 +58,6 @@ class MigrationsPlugin extends BasePlugin public function bootstrap(PluginApplicationInterface $app): void { parent::bootstrap($app); - - if (!Configure::check('Migrations.backend')) { - Configure::write('Migrations.backend', 'builtin'); - } } /** @@ -94,53 +68,36 @@ public function bootstrap(PluginApplicationInterface $app): void */ public function console(CommandCollection $commands): CommandCollection { - if (Configure::read('Migrations.backend') == 'builtin') { - $classes = [ - DumpCommand::class, - EntryCommand::class, - MarkMigratedCommand::class, - MigrateCommand::class, - RollbackCommand::class, - SeedCommand::class, - StatusCommand::class, - ]; - $hasBake = class_exists(SimpleBakeCommand::class); - if ($hasBake) { - $classes[] = BakeMigrationCommand::class; - $classes[] = BakeMigrationDiffCommand::class; - $classes[] = BakeMigrationSnapshotCommand::class; - $classes[] = BakeSeedCommand::class; - } - $found = []; - foreach ($classes as $class) { - $name = $class::defaultName(); - // If the short name has been used, use the full name. - // This allows app commands to have name preference. - // and app commands to overwrite migration commands. - if (!$commands->has($name)) { - $found[$name] = $class; - } - $found['migrations.' . $name] = $class; - } - if ($hasBake) { - $found['migrations create'] = BakeMigrationCommand::class; - } - - $commands->addMany($found); + $migrationClasses = [ + EntryCommand::class, + DumpCommand::class, + MarkMigratedCommand::class, + MigrateCommand::class, + RollbackCommand::class, + StatusCommand::class, + ]; - return $commands; + // Only show upgrade command if not explicitly using unified table + // (i.e., when legacyTables is null/autodetect or true) + if (Configure::read('Migrations.legacyTables') !== false) { + $migrationClasses[] = UpgradeCommand::class; } - $classes = $this->migrationCommandsList; - if (class_exists(SimpleBakeCommand::class)) { - $classes[] = BakeMigrationCommand::class; - $classes[] = BakeMigrationDiffCommand::class; - $classes[] = BakeMigrationSnapshotCommand::class; - $classes[] = BakeSeedCommand::class; + $seedClasses = [ + SeedsEntryCommand::class, + SeedCommand::class, + SeedResetCommand::class, + SeedStatusCommand::class, + ]; + $hasBake = class_exists(SimpleBakeCommand::class); + if ($hasBake) { + $migrationClasses[] = BakeMigrationCommand::class; + $migrationClasses[] = BakeMigrationDiffCommand::class; + $migrationClasses[] = BakeMigrationSnapshotCommand::class; + $migrationClasses[] = BakeSeedCommand::class; } - $found = []; - foreach ($classes as $class) { + foreach ($migrationClasses as $class) { $name = $class::defaultName(); // If the short name has been used, use the full name. // This allows app commands to have name preference. @@ -148,10 +105,21 @@ public function console(CommandCollection $commands): CommandCollection if (!$commands->has($name)) { $found[$name] = $class; } - // full name $found['migrations.' . $name] = $class; } + foreach ($seedClasses as $class) { + $name = $class::defaultName(); + if (!$commands->has($name)) { + $found[$name] = $class; + } + $found['seeds.' . $name] = $class; + } + if ($hasBake) { + $found['migrations create'] = BakeMigrationCommand::class; + } + + $commands->addMany($found); - return $commands->addMany($found); + return $commands; } } diff --git a/src/SeedInterface.php b/src/SeedInterface.php index 50ee1285c..3d21972a7 100644 --- a/src/SeedInterface.php +++ b/src/SeedInterface.php @@ -15,8 +15,6 @@ /** * Seed interface - * - * Implements the same API as Phinx's SeedInterface does but with migrations classes. */ interface SeedInterface { @@ -144,6 +142,47 @@ public function fetchAll(string $sql): array; */ public function insert(string $tableName, array $data): void; + /** + * Insert data into a table, skipping rows that would cause duplicate key conflicts. + * + * This method is idempotent and safe to run multiple times. + * Uses INSERT IGNORE (MySQL), ON CONFLICT DO NOTHING (PostgreSQL), + * or INSERT OR IGNORE (SQLite). + * + * @param string $tableName Table name + * @param array $data Data + * @return void + */ + public function insertOrSkip(string $tableName, array $data): void; + + /** + * Insert data into a table, updating specified columns on duplicate key conflicts. + * + * This method performs an "upsert" operation - inserting new rows and updating + * existing rows that conflict on the specified unique columns. + * + * ### Database-specific behavior: + * + * - **MySQL**: Uses `ON DUPLICATE KEY UPDATE`. The `$conflictColumns` parameter is + * ignored because MySQL automatically applies the update to all unique constraint + * violations. Passing `$conflictColumns` will trigger a warning. + * + * - **PostgreSQL/SQLite**: Uses `ON CONFLICT (...) DO UPDATE SET`. The `$conflictColumns` + * parameter is required and must specify the columns that have a unique constraint. + * A RuntimeException will be thrown if this parameter is empty. + * + * - **SQL Server**: Not currently supported. Use separate insert/update logic. + * + * @param string $tableName Table name + * @param array $data Data + * @param array $updateColumns Columns to update when a conflict occurs + * @param array $conflictColumns Columns that define uniqueness. Required for PostgreSQL/SQLite, + * ignored by MySQL (triggers warning if provided). + * @return void + * @throws \RuntimeException When using PostgreSQL or SQLite without specifying conflictColumns + */ + public function insertOrUpdate(string $tableName, array $data, array $updateColumns, array $conflictColumns): void; + /** * Checks to see if a table exists. * @@ -174,6 +213,19 @@ public function table(string $tableName, array $options = []): Table; */ public function shouldExecute(): bool; + /** + * Checks if this seed is idempotent (can run multiple times safely). + * + * Returns false by default, meaning the seed will be tracked and only run once. + * + * If you return true, the seed will NOT be tracked in the cake_seeds table, + * allowing it to run every time. Make sure your seed is truly idempotent + * (handles duplicate data safely) before returning true. + * + * @return bool + */ + public function isIdempotent(): bool; + /** * Gives the ability to a seeder to call another seeder. * This is particularly useful if you need to run the seeders of your applications in a specific sequences, diff --git a/src/Shim/MigrationAdapter.php b/src/Shim/MigrationAdapter.php deleted file mode 100644 index 5ac6003cc..000000000 --- a/src/Shim/MigrationAdapter.php +++ /dev/null @@ -1,405 +0,0 @@ -migration = new $migrationClass('default', $version); - } else { - if (!is_subclass_of($migrationClass, PhinxMigrationInterface::class)) { - throw new RuntimeException( - 'The provided $migrationClass must be a ' . - 'subclass of Phinx\Migration\MigrationInterface', - ); - } - $this->migration = $migrationClass; - } - } - - /** - * Because we're a compatibility shim, we implement this hook - * so that it can be conditionally called when it is implemented. - * - * @return void - */ - public function init(): void - { - if (method_exists($this->migration, MigrationInterface::INIT)) { - $this->migration->{MigrationInterface::INIT}(); - } - } - - /** - * Compatibility shim for executing change/up/down - */ - public function applyDirection(string $direction): void - { - $adapter = $this->getAdapter(); - - // Run the migration - if (method_exists($this->migration, MigrationInterface::CHANGE)) { - if ($direction === MigrationInterface::DOWN) { - // Create an instance of the RecordingAdapter so we can record all - // of the migration commands for reverse playback - $adapter = $this->migration->getAdapter(); - assert($adapter !== null, 'Adapter must be set in migration'); - - /** @var \Phinx\Db\Adapter\ProxyAdapter $proxyAdapter */ - $proxyAdapter = PhinxAdapterFactory::instance() - ->getWrapper('proxy', $adapter); - - // Wrap the adapter with a phinx shim to maintain contain - $this->migration->setAdapter($proxyAdapter); - - $this->migration->{MigrationInterface::CHANGE}(); - $proxyAdapter->executeInvertedCommands(); - - $this->migration->setAdapter($adapter); - } else { - $this->migration->{MigrationInterface::CHANGE}(); - } - } else { - $this->migration->{$direction}(); - } - } - - /** - * {@inheritDoc} - */ - public function setAdapter(AdapterInterface $adapter) - { - $phinxAdapter = new PhinxAdapter($adapter); - $this->migration->setAdapter($phinxAdapter); - $this->adapter = $adapter; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function getAdapter(): AdapterInterface - { - if (!$this->adapter) { - throw new RuntimeException('Cannot call getAdapter() until after setAdapter().'); - } - - return $this->adapter; - } - - /** - * {@inheritDoc} - */ - public function setIo(ConsoleIo $io) - { - $this->io = $io; - $this->migration->setOutput(new OutputAdapter($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) - { - $input = new ArrayInput([ - '--plugin' => $config['plugin'] ?? null, - '--source' => $config['source'] ?? null, - '--connection' => $config->getConnection(), - ]); - - $this->migration->setInput($input); - $this->config = $config; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function getName(): string - { - return $this->migration->getName(); - } - - /** - * {@inheritDoc} - */ - public function setVersion(int $version) - { - $this->migration->setVersion($version); - - return $this; - } - - /** - * {@inheritDoc} - */ - public function getVersion(): int - { - return $this->migration->getVersion(); - } - - /** - * {@inheritDoc} - */ - public function useTransactions(): bool - { - if (method_exists($this->migration, 'useTransactions')) { - return $this->migration->useTransactions(); - } - - return $this->migration->getAdapter()->hasTransactions(); - } - - /** - * {@inheritDoc} - */ - public function setMigratingUp(bool $isMigratingUp) - { - $this->migration->setMigratingUp($isMigratingUp); - - return $this; - } - - /** - * {@inheritDoc} - */ - public function isMigratingUp(): bool - { - return $this->migration->isMigratingUp(); - } - - /** - * {@inheritDoc} - */ - public function execute(string $sql, array $params = []): int - { - return $this->migration->execute($sql, $params); - } - - /** - * {@inheritDoc} - */ - public function query(string $sql, array $params = []): mixed - { - return $this->migration->query($sql, $params); - } - - /** - * {@inheritDoc} - */ - public function getQueryBuilder(string $type): Query - { - return $this->migration->getQueryBuilder($type); - } - - /** - * {@inheritDoc} - */ - public function getSelectBuilder(): SelectQuery - { - return $this->migration->getSelectBuilder(); - } - - /** - * {@inheritDoc} - */ - public function getInsertBuilder(): InsertQuery - { - return $this->migration->getInsertBuilder(); - } - - /** - * {@inheritDoc} - */ - public function getUpdateBuilder(): UpdateQuery - { - return $this->migration->getUpdateBuilder(); - } - - /** - * {@inheritDoc} - */ - public function getDeleteBuilder(): DeleteQuery - { - return $this->migration->getDeleteBuilder(); - } - - /** - * {@inheritDoc} - */ - public function fetchRow(string $sql): array|false - { - return $this->migration->fetchRow($sql); - } - - /** - * {@inheritDoc} - */ - public function fetchAll(string $sql): array - { - return $this->migration->fetchAll($sql); - } - - /** - * {@inheritDoc} - */ - public function createDatabase(string $name, array $options): void - { - $this->migration->createDatabase($name, $options); - } - - /** - * {@inheritDoc} - */ - public function dropDatabase(string $name): void - { - $this->migration->dropDatabase($name); - } - - /** - * {@inheritDoc} - */ - public function createSchema(string $name): void - { - $this->migration->createSchema($name); - } - - /** - * {@inheritDoc} - */ - public function dropSchema(string $name): void - { - $this->migration->dropSchema($name); - } - - /** - * {@inheritDoc} - */ - public function hasTable(string $tableName): bool - { - return $this->migration->hasTable($tableName); - } - - /** - * {@inheritDoc} - */ - public function table(string $tableName, array $options = []): Table - { - throw new RuntimeException('MigrationAdapter::table is not implemented'); - } - - /** - * {@inheritDoc} - */ - public function preFlightCheck(): void - { - $this->migration->preFlightCheck(); - } - - /** - * {@inheritDoc} - */ - public function postFlightCheck(): void - { - $this->migration->postFlightCheck(); - } - - /** - * {@inheritDoc} - */ - public function shouldExecute(): bool - { - return $this->migration->shouldExecute(); - } -} diff --git a/src/Shim/OutputAdapter.php b/src/Shim/OutputAdapter.php deleted file mode 100644 index a2fe3fc2a..000000000 --- a/src/Shim/OutputAdapter.php +++ /dev/null @@ -1,145 +0,0 @@ -io->out($messages, $newline ? 1 : 0); - } - - /** - * @inheritDoc - */ - public function writeln(string|iterable $messages, $options = 0): void - { - if ($messages instanceof Traversable) { - $messages = iterator_to_array($messages); - } - $this->io->out($messages, 1); - } - - /** - * Sets the verbosity of the output. - * - * @param self::VERBOSITY_* $level - * @return void - */ - public function setVerbosity(int $level): void - { - // TODO map values - $this->io->level($level); - } - - /** - * Gets the current verbosity of the output. - * - * @see self::VERBOSITY_* - * @return int - */ - public function getVerbosity(): int - { - // TODO map values - return $this->io->level(); - } - - /** - * Returns whether verbosity is quiet (-q). - */ - public function isQuiet(): bool - { - return $this->io->level() === ConsoleIo::QUIET; - } - - /** - * Returns whether verbosity is verbose (-v). - */ - public function isVerbose(): bool - { - return $this->io->level() === ConsoleIo::VERBOSE; - } - - /** - * Returns whether verbosity is very verbose (-vv). - */ - public function isVeryVerbose(): bool - { - return false; - } - - /** - * Returns whether verbosity is debug (-vvv). - */ - public function isDebug(): bool - { - return false; - } - - /** - * Sets the decorated flag. - * - * @return void - */ - public function setDecorated(bool $decorated): void - { - throw new RuntimeException('setDecorated is not implemented'); - } - - /** - * Gets the decorated flag. - */ - public function isDecorated(): bool - { - throw new RuntimeException('isDecorated is not implemented'); - } - - /** - * @return void - */ - public function setFormatter(OutputFormatterInterface $formatter): void - { - throw new RuntimeException('setFormatter is not implemented'); - } - - /** - * Returns current output formatter instance. - */ - public function getFormatter(): OutputFormatterInterface - { - throw new RuntimeException('getFormatter is not implemented'); - } -} diff --git a/src/Shim/SeedAdapter.php b/src/Shim/SeedAdapter.php deleted file mode 100644 index f5028b879..000000000 --- a/src/Shim/SeedAdapter.php +++ /dev/null @@ -1,252 +0,0 @@ -seed, PhinxSeedInterface::INIT)) { - $this->seed->{PhinxSeedInterface::INIT}(); - } - } - - /** - * {@inheritDoc} - */ - public function run(): void - { - $this->seed->run(); - } - - /** - * {@inheritDoc} - */ - public function getDependencies(): array - { - return $this->seed->getDependencies(); - } - - /** - * {@inheritDoc} - */ - public function setAdapter(AdapterInterface $adapter) - { - $phinxAdapter = new PhinxAdapter($adapter); - $this->seed->setAdapter($phinxAdapter); - $this->adapter = $adapter; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function getAdapter(): AdapterInterface - { - if (!$this->adapter) { - throw new RuntimeException('Cannot call getAdapter() until after setAdapter().'); - } - - return $this->adapter; - } - - /** - * {@inheritDoc} - */ - public function setIo(ConsoleIo $io) - { - $this->io = $io; - $this->seed->setOutput(new OutputAdapter($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) - { - $optionDef = new InputDefinition([ - new InputOption('plugin', mode: InputOption::VALUE_OPTIONAL, default: ''), - new InputOption('connection', mode: InputOption::VALUE_OPTIONAL, default: ''), - new InputOption('source', mode: InputOption::VALUE_OPTIONAL, default: ''), - ]); - $input = new ArrayInput([ - '--plugin' => $config['plugin'] ?? null, - '--source' => $config['source'] ?? null, - '--connection' => $config->getConnection(), - ], $optionDef); - - $this->seed->setInput($input); - $this->config = $config; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function getName(): string - { - return $this->seed->getName(); - } - - /** - * {@inheritDoc} - */ - public function execute(string $sql, array $params = []): int - { - return $this->seed->execute($sql, $params); - } - - /** - * {@inheritDoc} - */ - public function query(string $sql, array $params = []): mixed - { - return $this->seed->query($sql, $params); - } - - /** - * {@inheritDoc} - */ - public function fetchRow(string $sql): array|false - { - return $this->seed->fetchRow($sql); - } - - /** - * {@inheritDoc} - */ - public function fetchAll(string $sql): array - { - return $this->seed->fetchAll($sql); - } - - /** - * {@inheritDoc} - */ - public function insert(string $tableName, array $data): void - { - $this->seed->insert($tableName, $data); - } - - /** - * {@inheritDoc} - */ - public function hasTable(string $tableName): bool - { - return $this->seed->hasTable($tableName); - } - - /** - * {@inheritDoc} - */ - public function table(string $tableName, array $options = []): Table - { - throw new RuntimeException('Not implemented'); - } - - /** - * {@inheritDoc} - */ - public function shouldExecute(): bool - { - return $this->seed->shouldExecute(); - } - - /** - * {@inheritDoc} - */ - public function call(string $seeder, array $options = []): void - { - throw new RuntimeException('Not implemented'); - } -} diff --git a/src/Table.php b/src/Table.php deleted file mode 100644 index f43f3b41a..000000000 --- a/src/Table.php +++ /dev/null @@ -1,249 +0,0 @@ -primaryKey = $columns; - - return $this; - } - - /** - * {@inheritDoc} - * - * You can pass `autoIncrement` as an option and it will be converted - * to the correct option for phinx to create the column with an - * auto increment attribute - * - * @param string|\Phinx\Db\Table\Column $columnName Column Name - * @param string|\Phinx\Util\Literal|null $type Column Type - * @param array $options Column Options - * @throws \InvalidArgumentException - * @return $this - */ - public function addColumn(Column|string $columnName, string|Literal|null $type = null, $options = []) - { - $options = $this->convertedAutoIncrement($options); - - return parent::addColumn($columnName, $type, $options); - } - - /** - * {@inheritDoc} - * - * You can pass `autoIncrement` as an option and it will be converted - * to the correct option for phinx to create the column with an - * auto increment attribute - * - * @param string $columnName Column Name - * @param string|\Phinx\Db\Table\Column|\Phinx\Util\Literal $newColumnType New Column Type - * @param array $options Options - * @return $this - */ - public function changeColumn(string $columnName, string|Column|Literal $newColumnType, array $options = []) - { - $options = $this->convertedAutoIncrement($options); - - return parent::changeColumn($columnName, $newColumnType, $options); - } - - /** - * Convert the `autoIncrement` option to the correct options for phinx. - * - * @param array $options Options - * @return array Converted options - */ - protected function convertedAutoIncrement(array $options): array - { - if (isset($options['autoIncrement']) && $options['autoIncrement'] === true) { - $options['identity'] = true; - unset($options['autoIncrement']); - } - - return $options; - } - - /** - * {@inheritDoc} - * - * If using MySQL and no collation information has been given to the table options, a request to the information - * schema will be made to get the default database collation and apply it to the database. This is to prevent - * phinx default mechanism to put the collation to a default of "utf8_general_ci". - * - * @return void - */ - public function create(): void - { - $options = $this->getTable()->getOptions(); - if ((!isset($options['id']) || $options['id'] === false) && !empty($this->primaryKey)) { - $options['primary_key'] = $this->primaryKey; - $this->filterPrimaryKey(); - } - - if ($this->getAdapter()->getAdapterType() === 'mysql' && empty($options['collation'])) { - $encodingRequest = 'SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME - FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :dbname'; - - $cakeConnection = $this->getAdapter()->getCakeConnection(); - $connectionConfig = $cakeConnection->config(); - - $statement = $cakeConnection->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); - - parent::create(); - } - - /** - * {@inheritDoc} - * - * After a table update, the TableRegistry should be cleared in order to prevent issues with - * table schema stored in Table objects having columns that might have been renamed or removed during - * the update process. - * - * @return void - */ - public function update(): void - { - parent::update(); - $this->getTableLocator()->clear(); - } - - /** - * {@inheritDoc} - * - * We disable foreign key deletion for the SQLite adapter as SQLite does not support the feature natively and the - * process implemented by Phinx has serious side-effects (for instance it renames FK references in existing tables - * which breaks the database schema cohesion). - * - * @param string|array $columns Column(s) - * @param string|null $constraint Constraint names - * @return $this - */ - public function dropForeignKey($columns, $constraint = null) - { - if ($this->getAdapter()->getAdapterType() === 'sqlite') { - return $this; - } - - return parent::dropForeignKey($columns, $constraint); - } - - /** - * 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(): void - { - $options = $this->getTable()->getOptions(); - 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 (!$primaryKeyColumns) { - return; - } - - foreach ($primaryKeyColumns as $primaryKeyColumn) { - if ($primaryKeyColumn->isIdentity()) { - unset($primaryKey[$primaryKeyColumn->getName()]); - } - } - - $primaryKey = array_flip($primaryKey); - - if ($primaryKey) { - $options['primary_key'] = $primaryKey; - } else { - unset($options['primary_key']); - } - - $this->getTable()->setOptions($options); - } - - /** - * @inheritDoc - */ - public function addTimestamps($createdAt = '', $updatedAt = '', bool $withTimezone = false) - { - $createdAt = $createdAt ?: 'created'; - $updatedAt = $updatedAt ?: 'modified'; - - return parent::addTimestamps($createdAt, $updatedAt, $withTimezone); - } -} diff --git a/src/TestSuite/Migrator.php b/src/TestSuite/Migrator.php index 6f2400d50..a0c30160b 100644 --- a/src/TestSuite/Migrator.php +++ b/src/TestSuite/Migrator.php @@ -264,6 +264,7 @@ protected function getNonPhinxTables(string $connection, array $skip): array assert($connection instanceof Connection); $tables = $connection->getSchemaCollection()->listTables(); $skip[] = '*phinxlog*'; + $skip[] = 'cake_migrations'; return array_filter($tables, function ($table) use ($skip) { foreach ($skip as $pattern) { diff --git a/src/Util/ColumnParser.php b/src/Util/ColumnParser.php index 02a1b5ee8..97b9efdc0 100644 --- a/src/Util/ColumnParser.php +++ b/src/Util/ColumnParser.php @@ -28,6 +28,7 @@ class ColumnParser (?:,(?:[0-9]|[1-9][0-9]+))? \])? ))? + (?::default\[([^\]]+)\])? (?::(\w+))? (?::(\w+))? $ @@ -54,7 +55,8 @@ public function parseFields(array $arguments): array preg_match($this->regexpParseColumn, $field, $matches); $field = $matches[1]; $type = Hash::get($matches, 2, ''); - $indexType = Hash::get($matches, 3); + $defaultValue = Hash::get($matches, 3); + $indexType = Hash::get($matches, 4); $typeIsPk = in_array($type, ['primary', 'primary_key'], true); $isPrimaryKey = false; @@ -72,7 +74,7 @@ public function parseFields(array $arguments): array $type = str_contains($type, '?') ? 'integer?' : 'integer'; } - $nullable = (bool)strpos($type, '?'); + $nullable = str_contains($type, '?'); $type = $nullable ? str_replace('?', '', $type) : $type; [$type, $length] = $this->getTypeAndLength($field, $type); @@ -80,7 +82,7 @@ public function parseFields(array $arguments): array 'columnType' => $type, 'options' => [ 'null' => $nullable, - 'default' => null, + 'default' => $this->parseDefaultValue($defaultValue, $type ?? 'string'), ], ]; @@ -114,8 +116,8 @@ public function parseIndexes(array $arguments): array preg_match($this->regexpParseColumn, $field, $matches); $field = $matches[1]; $type = Hash::get($matches, 2); - $indexType = Hash::get($matches, 3); - $indexName = Hash::get($matches, 4); + $indexType = Hash::get($matches, 4); + $indexName = Hash::get($matches, 5); // Skip references - they create foreign keys, not indexes if ($type && str_starts_with($type, 'references')) { @@ -168,7 +170,7 @@ public function parsePrimaryKey(array $arguments): array preg_match($this->regexpParseColumn, $field, $matches); $field = $matches[1]; $type = Hash::get($matches, 2); - $indexType = Hash::get($matches, 3); + $indexType = Hash::get($matches, 4); if ( in_array($type, ['primary', 'primary_key'], true) @@ -196,8 +198,8 @@ public function parseForeignKeys(array $arguments): array preg_match($this->regexpParseColumn, $field, $matches); $fieldName = $matches[1]; $type = Hash::get($matches, 2, ''); - $indexType = Hash::get($matches, 3); - $indexName = Hash::get($matches, 4); + $indexType = Hash::get($matches, 4); + $indexName = Hash::get($matches, 5); // Check if type is 'references' or 'references?' $isReference = str_starts_with($type, 'references'); @@ -250,17 +252,20 @@ public function validArguments(array $arguments): array * * @param string $field Name of field * @param string|null $type User-specified type - * @return array First value is the field type, second value is the field length. If no length + * @return array{0: string|null, 1: int|array|null} First value is the field type, second value is the field length. If no length * can be extracted, null is returned for the second value */ public function getTypeAndLength(string $field, ?string $type): array { if ($type && preg_match($this->regexpParseField, $type, $matches)) { - if (str_contains($matches[2], ',')) { - $matches[2] = explode(',', $matches[2]); + $length = $matches[2]; + if (str_contains($length, ',')) { + $length = array_map('intval', explode(',', $length)); + } else { + $length = (int)$length; } - return [$matches[1], $matches[2]]; + return [$matches[1], $length]; } /** @var string $fieldType */ @@ -283,7 +288,8 @@ public function getType(string $field, ?string $type): ?string $collection = new Collection($reflector->getConstants()); $validTypes = $collection->filter(function ($value, $constant) { - return substr($constant, 0, strlen('PHINX_TYPE_')) === 'PHINX_TYPE_'; + return substr($constant, 0, strlen('TYPE_')) === 'TYPE_' || + substr($constant, 0, strlen('PHINX_TYPE_')) === 'PHINX_TYPE_'; })->toArray(); $fieldType = $type; if ($type === null || !in_array($type, $validTypes, true)) { @@ -351,4 +357,61 @@ public function getIndexName(string $field, ?string $indexType, ?string $indexNa return $indexName; } + + /** + * Parses a default value string into the appropriate PHP type. + * + * Supports: + * - Booleans: true, false + * - Null: null, NULL + * - Integers: 123, -123 + * - Floats: 1.5, -1.5 + * - Strings: 'hello' (quoted) or unquoted values + * + * @param string|null $value The raw default value from the command line + * @param string $columnType The column type to help with type coercion + * @return string|int|float|bool|null The parsed default value + */ + public function parseDefaultValue(?string $value, string $columnType): string|int|float|bool|null + { + if ($value === null || $value === '') { + return null; + } + + $lowerValue = strtolower($value); + + // Handle null + if ($lowerValue === 'null') { + return null; + } + + // Handle booleans + if ($lowerValue === 'true') { + return true; + } + if ($lowerValue === 'false') { + return false; + } + + // Handle quoted strings - strip quotes + if ( + (str_starts_with($value, "'") && str_ends_with($value, "'")) || + (str_starts_with($value, '"') && str_ends_with($value, '"')) + ) { + return substr($value, 1, -1); + } + + // Handle integers + if (preg_match('/^-?[0-9]+$/', $value)) { + return (int)$value; + } + + // Handle floats + if (preg_match('/^-?[0-9]+\.[0-9]+$/', $value)) { + return (float)$value; + } + + // Return as-is for SQL expressions like CURRENT_TIMESTAMP + return $value; + } } diff --git a/src/Util/SchemaTrait.php b/src/Util/SchemaTrait.php deleted file mode 100644 index 853fd1b78..000000000 --- a/src/Util/SchemaTrait.php +++ /dev/null @@ -1,66 +0,0 @@ -getOption('connection'); - /** @var \Cake\Database\Connection|\Cake\Datasource\ConnectionInterface $connection */ - $connection = ConnectionManager::get($connectionName); - - if (!method_exists($connection, 'getSchemaCollection')) { - $msg = sprintf( - 'The `%s` connection is not compatible with ORM caching, ' . - 'as it does not implement a `getSchemaCollection()` method.', - $connectionName, - ); - $output->writeln('' . $msg . ''); - - return null; - } - - $config = $connection->config(); - - if (empty($config['cacheMetadata'])) { - $output->writeln('Metadata cache was disabled in config. Enable to cache or clear.'); - - return null; - } - - $connection->cacheMetadata(true); - - /** - * @var \Cake\Database\Schema\CachedCollection - */ - return $connection->getSchemaCollection(); - } -} diff --git a/src/Util/TableFinder.php b/src/Util/TableFinder.php index 63e7ebf6e..a96b4e03b 100644 --- a/src/Util/TableFinder.php +++ b/src/Util/TableFinder.php @@ -30,7 +30,7 @@ class TableFinder * * @var string[] */ - public array $skipTables = ['phinxlog']; + public array $skipTables = ['phinxlog', 'cake_migrations']; /** * Regex of Table name to skip diff --git a/src/Util/Util.php b/src/Util/Util.php index a0435b41e..e8138cd09 100644 --- a/src/Util/Util.php +++ b/src/Util/Util.php @@ -8,9 +8,11 @@ namespace Migrations\Util; +use Cake\Core\Configure; use Cake\Utility\Inflector; use DateTime; use DateTimeZone; +use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; use RuntimeException; /** @@ -37,6 +39,15 @@ class Util */ protected const MIGRATION_FILE_NAME_NO_NAME_PATTERN = '/^[0-9]{14}\.php$/'; + /** + * Enhanced migration file name pattern with readable timestamp and CamelCase + * Example: 2024_12_08_120000_CreateUsersTable.php + * + * @var string + * @phpstan-var non-empty-string + */ + protected const READABLE_MIGRATION_FILE_NAME_PATTERN = '/^(\d{4})_(\d{2})_(\d{2})_(\d{6})_([A-Z][a-zA-Z\d]*)\.php$/'; + /** * @var string * @phpstan-var non-empty-string @@ -95,7 +106,16 @@ public static function getExistingMigrationClassNames(string $path): array public static function getVersionFromFileName(string $fileName): int { $matches = []; - preg_match('/^[0-9]+/', basename($fileName), $matches); + $baseName = basename($fileName); + + // Check for readable format: 2024_12_08_120000_CreateUsersTable.php + if (preg_match(static::READABLE_MIGRATION_FILE_NAME_PATTERN, $baseName, $matches)) { + // Convert to traditional format: 20241208120000 + return (int)($matches[1] . $matches[2] . $matches[3] . $matches[4]); + } + + // Traditional format + preg_match('/^[0-9]+/', $baseName, $matches); $value = (int)($matches[0] ?? null); if (!$value) { throw new RuntimeException(sprintf('Cannot get a valid version from filename `%s`', $fileName)); @@ -114,8 +134,6 @@ public static function getVersionFromFileName(string $fileName): int */ public static function mapClassNameToFileName(string $className): string { - // TODO it would be nice to replace this with Inflector::underscore - // but it will break compatibility for little end user gain. $snake = function ($matches) { return '_' . strtolower($matches[0]); }; @@ -135,6 +153,12 @@ public static function mapClassNameToFileName(string $className): string public static function mapFileNameToClassName(string $fileName): string { $matches = []; + + // Check for readable format first: 2024_12_08_120000_CreateUsersTable.php + if (preg_match(static::READABLE_MIGRATION_FILE_NAME_PATTERN, $fileName, $matches)) { + return $matches[5]; // Return the CamelCase class name directly + } + if (preg_match(static::MIGRATION_FILE_NAME_PATTERN, $fileName, $matches)) { $fileName = $matches[1]; } elseif (preg_match(static::MIGRATION_FILE_NAME_NO_NAME_PATTERN, $fileName)) { @@ -153,7 +177,8 @@ public static function mapFileNameToClassName(string $fileName): string public static function isValidMigrationFileName(string $fileName): bool { return (bool)preg_match(static::MIGRATION_FILE_NAME_PATTERN, $fileName) - || (bool)preg_match(static::MIGRATION_FILE_NAME_NO_NAME_PATTERN, $fileName); + || (bool)preg_match(static::MIGRATION_FILE_NAME_NO_NAME_PATTERN, $fileName) + || (bool)preg_match(static::READABLE_MIGRATION_FILE_NAME_PATTERN, $fileName); } /** @@ -167,6 +192,23 @@ public static function isValidSeedFileName(string $fileName): bool return (bool)preg_match(static::SEED_FILE_NAME_PATTERN, $fileName); } + /** + * Get a human-readable display name for a seed class. + * + * Strips the 'Seed' suffix from class names like 'UsersSeed' to produce 'Users'. + * + * @param string $seedName The seed class name + * @return string The display name without the 'Seed' suffix + */ + public static function getSeedDisplayName(string $seedName): string + { + if (str_ends_with($seedName, 'Seed')) { + return substr($seedName, 0, -4); + } + + return $seedName; + } + /** * Expands a set of paths with curly braces (if supported by the OS). * @@ -226,6 +268,11 @@ public static function getFiles(string|array $paths): array */ public static function tableName(?string $plugin): string { + // When using unified table, always return the same table name + if (Configure::read('Migrations.legacyTables') === false) { + return UnifiedMigrationsTableStorage::TABLE_NAME; + } + $table = 'phinxlog'; if ($plugin) { $prefix = Inflector::underscore($plugin) . '_'; diff --git a/src/Util/UtilTrait.php b/src/Util/UtilTrait.php index bc464cf4a..fc3ab3b89 100644 --- a/src/Util/UtilTrait.php +++ b/src/Util/UtilTrait.php @@ -13,9 +13,10 @@ */ namespace Migrations\Util; -use Cake\Core\Plugin as CorePlugin; +use Cake\Core\Configure; +use Cake\Database\Connection; use Cake\Utility\Inflector; -use Symfony\Component\Console\Input\InputInterface; +use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; /** * Trait gathering useful methods needed in various places of the plugin @@ -23,25 +24,50 @@ trait UtilTrait { /** - * Get the plugin name based on the current InputInterface + * Get the migrations table name used to store migrations data. * - * @param \Symfony\Component\Console\Input\InputInterface $input Input of the current command. - * @return string|null + * In v5.0+, this returns either: + * - 'cake_migrations' (unified table) for new installations + * - Legacy phinxlog table names for existing installations with phinxlog tables + * + * The behavior is controlled by `Migrations.legacyTables` config: + * - null (default): Autodetect - use legacy if phinxlog tables exist + * - false: Always use new cake_migrations table + * - true: Always use legacy phinxlog tables + * + * @param string|null $plugin Plugin name + * @param \Cake\Database\Connection|null $connection Database connection for autodetect + * @return string */ - protected function getPlugin(InputInterface $input): ?string + protected function getPhinxTable(?string $plugin = null, ?Connection $connection = null): string { - $plugin = $input->getOption('plugin') ?: null; + $config = Configure::read('Migrations.legacyTables'); + + // Explicit configuration takes precedence + if ($config === false) { + return UnifiedMigrationsTableStorage::TABLE_NAME; + } + + if ($config === true) { + return $this->getLegacyTableName($plugin); + } + + // Autodetect mode (config is null or not set) + if ($connection !== null && $this->detectLegacyTables($connection)) { + return $this->getLegacyTableName($plugin); + } - return $plugin; + // No legacy tables detected or no connection provided - use new table + return UnifiedMigrationsTableStorage::TABLE_NAME; } /** - * Get the phinx table name used to store migrations data + * Get the legacy phinxlog table name. * * @param string|null $plugin Plugin name * @return string */ - protected function getPhinxTable(?string $plugin = null): string + protected function getLegacyTableName(?string $plugin = null): string { $table = 'phinxlog'; @@ -56,28 +82,41 @@ protected function getPhinxTable(?string $plugin = null): string } /** - * Get the migrations or seeds files path based on the current InputInterface + * Detect if any legacy phinxlog tables exist in the database. * - * @param \Symfony\Component\Console\Input\InputInterface $input Input of the current command. - * @param string $default Default folder to set if no source option is found in the $input param - * @return string + * @param \Cake\Database\Connection $connection Database connection + * @return bool True if legacy tables exist */ - protected function getOperationsPath(InputInterface $input, string $default = 'Migrations'): string + protected function detectLegacyTables(Connection $connection): bool { - $folder = $input->getOption('source') ?: $default; + $dialect = $connection->getDriver()->schemaDialect(); - $dir = ROOT . DS . 'config' . DS . $folder; + return $dialect->hasTable('phinxlog'); + } - if (defined('CONFIG')) { - $dir = CONFIG . $folder; + /** + * Check if the system is using legacy migration tables. + * + * @param \Cake\Database\Connection|null $connection Database connection for autodetect + * @return bool + */ + protected function isUsingLegacyTables(?Connection $connection = null): bool + { + $config = Configure::read('Migrations.legacyTables'); + + if ($config === false) { + return false; } - $plugin = $this->getPlugin($input); + if ($config === true) { + return true; + } - if ($plugin !== null) { - $dir = CorePlugin::path($plugin) . 'config' . DS . $folder; + // Autodetect + if ($connection !== null) { + return $this->detectLegacyTables($connection); } - return $dir; + return false; } } diff --git a/src/View/Helper/MigrationHelper.php b/src/View/Helper/MigrationHelper.php index 017be4a98..3302e7e5c 100644 --- a/src/View/Helper/MigrationHelper.php +++ b/src/View/Helper/MigrationHelper.php @@ -23,6 +23,7 @@ use Cake\Utility\Inflector; use Cake\View\Helper; use Cake\View\View; +use Migrations\Db\Table\ForeignKey; /** * Migration Helper class for output of field data in migration files. @@ -256,14 +257,14 @@ public function constraints(TableSchemaInterface|string $table): array } /** - * Format a constraint action if it is not already in the format expected by Phinx + * Format a constraint action if it is not already in the format expected by migrations * * @param string $constraint Constraint action name * @return string Constraint action name altered if needed. */ public function formatConstraintAction(string $constraint): string { - if (defined('\Phinx\Db\Table\ForeignKey::' . $constraint)) { + if (defined(ForeignKey::class . '::' . $constraint)) { return $constraint; } @@ -357,11 +358,6 @@ public function column(TableSchemaInterface $tableSchema, string $column): array { $columnType = $tableSchema->getColumnType($column); - // Phinx doesn't understand timestampfractional or datetimefractional types - if ($columnType === 'timestampfractional' || $columnType === 'datetimefractional') { - $columnType = 'timestamp'; - } - return [ 'columnType' => $columnType, 'options' => $this->attributes($tableSchema, $column), @@ -390,6 +386,7 @@ public function getColumnOption(array $options): array 'comment', 'autoIncrement', 'precision', + 'scale', 'after', 'collate', ]); @@ -409,6 +406,10 @@ public function getColumnOption(array $options): array $isMysql = $driver instanceof Mysql; if (!$isMysql) { unset($columnOptions['signed']); + } elseif (isset($columnOptions['signed']) && $columnOptions['signed'] === true) { + // Remove 'signed' => true since signed is the default for integer columns + // Only output explicit 'signed' => false for unsigned columns + unset($columnOptions['signed']); } if (!empty($columnOptions['collate'])) { @@ -417,12 +418,17 @@ public function getColumnOption(array $options): array unset($columnOptions['collate']); } - // TODO this can be cleaned up when we stop using phinx data structures for column definitions + // Handle precision/scale conversion between CakePHP's TableSchema format and SQL standard format. + // TableSchema uses: length=total digits, precision=decimal places + // Migrations uses SQL standard: precision=total digits, scale=decimal places if (!isset($columnOptions['precision']) || $columnOptions['precision'] == null) { unset($columnOptions['precision']); } else { - // due to Phinx using different naming for the precision and scale to CakePHP - $columnOptions['scale'] = $columnOptions['precision']; + // Convert CakePHP's precision (decimal places) to Migrations' scale + // Only convert if scale is not already set (for decimal columns from diff) + if (!isset($columnOptions['scale'])) { + $columnOptions['scale'] = $columnOptions['precision']; + } if (isset($columnOptions['limit'])) { $columnOptions['precision'] = $columnOptions['limit']; @@ -528,6 +534,10 @@ public function attributes(TableSchemaInterface|string $table, string $column): $isMysql = $connection->getDriver() instanceof Mysql; if (!$isMysql) { unset($attributes['signed']); + } elseif (isset($attributes['signed']) && $attributes['signed'] === true) { + // Remove 'signed' => true since signed is now the default for integer columns + // Only output explicit 'signed' => false for unsigned columns + unset($attributes['signed']); } $defaultCollation = $tableSchema->getOptions()['collation'] ?? null; @@ -615,7 +625,7 @@ public function tableStatement(string $table, bool $reset = false): string if (!isset($this->tableStatementStatus[$table])) { $this->tableStatementStatus[$table] = true; - return '$this->table(\'' . $table . '\')'; + return '$this->table(\'' . addslashes($table) . '\')'; } return ''; @@ -649,7 +659,7 @@ public function resetTableStatementGenerationFor(string $table): void * Render an element. * * @param string $name The name of the element to render. - * @param array $data Additional data for the element. + * @param array $data Additional data for the element. * @return ?string */ public function element(string $name, array $data): ?string diff --git a/templates/Phinx/create.php.template b/templates/Phinx/create.php.template deleted file mode 100644 index 82a91ba02..000000000 --- a/templates/Phinx/create.php.template +++ /dev/null @@ -1,18 +0,0 @@ -table('{{ table }}'); + $table->insert($data)->save(); + } +}; diff --git a/templates/bake/Seed/seed.twig b/templates/bake/Seed/seed.twig index d6d691e1f..17b079d47 100644 --- a/templates/bake/Seed/seed.twig +++ b/templates/bake/Seed/seed.twig @@ -16,21 +16,12 @@ update(); @@ -112,7 +102,7 @@ not empty %} {{~ Migration.element( 'Migrations.add-foreign-keys', - {'constraints': tableDiff['constraints']['add'], 'table': tableName, 'backend': backend} + {'constraints': tableDiff['constraints']['add'], 'table': tableName} ) | raw -}} {%~ endif -%} {% endfor -%} @@ -128,7 +118,7 @@ not empty %} * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/templates/bake/config/skeleton-anonymous.twig b/templates/bake/config/skeleton-anonymous.twig new file mode 100644 index 000000000..1d19b868a --- /dev/null +++ b/templates/bake/config/skeleton-anonymous.twig @@ -0,0 +1,47 @@ +{# +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://www.opensource.org/licenses/mit-license.php MIT License + */ +#} +{% set wantedOptions = {'length': '', 'limit': '', 'default': '', 'unsigned': '', 'null': '', 'comment': '', 'autoIncrement': '', 'precision': '', 'scale': ''} %} +{% set tableMethod = Migration.tableMethod(action) %} +{% set columnMethod = Migration.columnMethod(action) %} +{% set indexMethod = Migration.indexMethod(action) %} +table('{{ table }}'); - {%~ if tableMethod != 'drop' %} - {%~ if columnMethod == 'removeColumn' %} - {%~ for column, config in columns['fields'] %} - $table->{{ columnMethod }}('{{ column }}'); - {%~ endfor %} - {%~ for column, config in columns['indexes'] %} - $table->{{ indexMethod }}([{{ - Migration.stringifyList(config['columns']) | raw - }}]); - {%~ endfor %} - {%~ else %} - {%~ for column, config in columns['fields'] %} - $table->{{ columnMethod }}('{{ column }}', '{{ config['columnType'] }}', [{{ - Migration.stringifyList(config['options'], {'indent': 3}, wantedOptions) | raw - }}]); - {%~ endfor %} - {%~ for column, config in columns['indexes'] %} - $table->{{ indexMethod }}([{{ - Migration.stringifyList(config['columns'], {'indent': 3}) | raw }} - ], [{{ - Migration.stringifyList(config['options'], {'indent': 3}) | raw - }}]); - {%~ endfor %} - {%~ if tableMethod == 'create' and columns['primaryKey'] is not empty %} - $table->addPrimaryKey([{{ - Migration.stringifyList(columns['primaryKey'], {'indent': 3}) | raw - }}]); - {%~ endif %} - {%~ if constraints is defined and constraints is not empty %} - {%~ for constraintName, constraint in constraints %} - {%~ if constraint['type'] == 'foreign' %} - {%~ set columnsList = '\'' ~ constraint['columns'][0] ~ '\'' %} - {%~ if constraint['columns']|length > 1 %} - {%~ set columnsList = '[' ~ Migration.stringifyList(constraint['columns'], {'indent': 3}) ~ ']' %} - {%~ endif %} - {%~ if constraint['references'][1] is iterable %} - {%~ set columnsReference = '[' ~ Migration.stringifyList(constraint['references'][1], {'indent': 3}) ~ ']' %} - {%~ else %} - {%~ set columnsReference = '\'' ~ constraint['references'][1] ~ '\'' %} - {%~ endif %} - {%~ if backend == 'builtin' %} - $table->addForeignKey( - $this->foreignKey({{ columnsList | raw }}) - ->setReferencedTable('{{ constraint['references'][0] }}') - ->setReferencedColumns({{ columnsReference | raw }}) - ->setOnDelete('{{ Migration.formatConstraintAction(constraint['delete']) | raw }}') - ->setOnUpdate('{{ Migration.formatConstraintAction(constraint['update']) | raw }}') - ->setName('{{ constraintName }}') - ); - {%~ else %} - $table->addForeignKey( - {{ columnsList | raw }}, - '{{ constraint['references'][0] }}', - {{ columnsReference | raw }}, - [ - 'update' => '{{ Migration.formatConstraintAction(constraint['update']) | raw }}', - 'delete' => '{{ Migration.formatConstraintAction(constraint['delete']) | raw }}', - 'constraint' => '{{ constraintName }}' - ] - ); - {%~ endif %} - {%~ endif %} - {%~ endfor %} - {%~ endif %} - {%~ endif %} -{% endif %} - $table->{{ tableMethod }}(){% if tableMethod == 'drop' %}->save(){% endif %}; -{% endfor %} +{{ element('Migrations.change-method-body', { + columnMethod: columnMethod, + indexMethod: indexMethod, + tableMethod: tableMethod, + wantedOptions: wantedOptions, +}) }} } } diff --git a/templates/bake/config/snapshot.twig b/templates/bake/config/snapshot.twig index 9ca3eb4af..42296d4e5 100644 --- a/templates/bake/config/snapshot.twig +++ b/templates/bake/config/snapshot.twig @@ -22,25 +22,37 @@ table('{{ table }}')->drop()->save(); {% endfor %} } +{% endif %} } diff --git a/templates/bake/element/add-foreign-keys.twig b/templates/bake/element/add-foreign-keys.twig index 4c99eee15..774cf80d8 100644 --- a/templates/bake/element/add-foreign-keys.twig +++ b/templates/bake/element/add-foreign-keys.twig @@ -6,14 +6,13 @@ {%~ set hasProcessedConstraint = true %} {%~ set columnsList = '\'' ~ constraint['columns'][0] ~ '\'' %} {%~ set storedColumnList = columnsList %} - {%~ set indent = backend == 'builtin' ? 6 : 5 %} {%~ if constraint['columns']|length > 1 %} {%~ set storedColumnList = '[' ~ Migration.stringifyList(constraint['columns'], {'indent': 5}) ~ ']' %} - {%~ set columnsList = '[' ~ Migration.stringifyList(constraint['columns'], {'indent': indent}) ~ ']' %} + {%~ set columnsList = '[' ~ Migration.stringifyList(constraint['columns'], {'indent': 6}) ~ ']' %} {%~ endif %} {%~ set record = Migration.storeReturnedData(table, storedColumnList) %} {%~ if constraint['references'][1] is iterable %} - {%~ set columnsReference = '[' ~ Migration.stringifyList(constraint['references'][1], {'indent': indent}) ~ ']' %} + {%~ set columnsReference = '[' ~ Migration.stringifyList(constraint['references'][1], {'indent': 6}) ~ ']' %} {%~ else %} {%~ set columnsReference = '\'' ~ constraint['references'][1] ~ '\'' %} {%~ endif %} @@ -25,7 +24,6 @@ {{ statement | raw }} {%~ set statement = null %} {%~ endif %} - {%~ if backend == 'builtin' %} ->addForeignKey( $this->foreignKey({{ columnsList | raw }}) ->setReferencedTable('{{ constraint['references'][0] }}') @@ -34,18 +32,6 @@ ->setOnUpdate('{{ Migration.formatConstraintAction(constraint['update']) | raw }}') ->setName('{{ constraintName }}') ) - {%~ else %} - ->addForeignKey( - {{ columnsList | raw }}, - '{{ constraint['references'][0] }}', - {{ columnsReference | raw }}, - [ - 'update' => '{{ Migration.formatConstraintAction(constraint['update']) | raw }}', - 'delete' => '{{ Migration.formatConstraintAction(constraint['delete']) | raw }}', - 'constraint' => '{{ constraintName }}' - ] - ) - {%~ endif %} {%~ endif %} {% endfor %} {% if Migration.wasTableStatementGeneratedFor(table) and hasProcessedConstraint %} diff --git a/templates/bake/element/add-indexes.twig b/templates/bake/element/add-indexes.twig index 6466b1a64..3e047dc07 100644 --- a/templates/bake/element/add-indexes.twig +++ b/templates/bake/element/add-indexes.twig @@ -4,25 +4,15 @@ {%~ set columnsList = '[' ~ Migration.stringifyList(index['columns'], {'indent': 6}) ~ ']' %} {%~ endif %} ->addIndex( - {%~ if backend == 'builtin' %} $this->index({{ columnsList | raw }}) ->setName('{{ indexName }}') - {%~ if index['type'] == 'unique' %} + {%~ if index['type'] == 'unique' %} ->setType('unique') - {%~ elseif index['type'] == 'fulltext' %} + {%~ elseif index['type'] == 'fulltext' %} ->setType('fulltext') - {%~ endif %} - {%~ if index['options'] %} + {%~ endif %} + {%~ if index['options'] %} ->setOptions([{{ Migration.stringifyList(index['options'], {'indent': 6}) | raw }}]) - {%~ endif %} - ) - {%~ else %} - [{{ Migration.stringifyList(index['columns'], {'indent': 5}) | raw }}], - {%~ set params = {'name': indexName} %} - {%~ if index['type'] == 'unique' %} - {%~ set params = params|merge({'unique': true}) %} - {%~ endif %} - [{{ Migration.stringifyList(params, {'indent': 5}) | raw }}] - ) {%~ endif %} + ) {% endfor %} diff --git a/templates/bake/element/change-method-body.twig b/templates/bake/element/change-method-body.twig new file mode 100644 index 000000000..e1370cd4f --- /dev/null +++ b/templates/bake/element/change-method-body.twig @@ -0,0 +1,72 @@ +{# +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 5.0.0 + * @license https://www.opensource.org/licenses/mit-license.php MIT License + */ +#} +{% for table in tables %} + $table = $this->table('{{ table }}'); + {%~ if tableMethod != 'drop' %} + {%~ if columnMethod == 'removeColumn' %} + {%~ for column, config in columns['fields'] %} + $table->{{ columnMethod }}('{{ column }}'); + {%~ endfor %} + {%~ for column, config in columns['indexes'] %} + $table->{{ indexMethod }}([{{ + Migration.stringifyList(config['columns']) | raw + }}]); + {%~ endfor %} + {%~ else %} + {%~ for column, config in columns['fields'] %} + $table->{{ columnMethod }}('{{ column }}', '{{ config['columnType'] }}', [{{ + Migration.stringifyList(config['options'], {'indent': 3}, wantedOptions) | raw + }}]); + {%~ endfor %} + {%~ for column, config in columns['indexes'] %} + $table->{{ indexMethod }}([{{ + Migration.stringifyList(config['columns'], {'indent': 3}) | raw }} + ], [{{ + Migration.stringifyList(config['options'], {'indent': 3}) | raw + }}]); + {%~ endfor %} + {%~ if tableMethod == 'create' and columns['primaryKey'] is not empty %} + $table->addPrimaryKey([{{ + Migration.stringifyList(columns['primaryKey'], {'indent': 3}) | raw + }}]); + {%~ endif %} + {%~ if constraints is defined and constraints is not empty %} + {%~ for constraintName, constraint in constraints %} + {%~ if constraint['type'] == 'foreign' %} + {%~ set columnsList = '\'' ~ constraint['columns'][0] ~ '\'' %} + {%~ if constraint['columns']|length > 1 %} + {%~ set columnsList = '[' ~ Migration.stringifyList(constraint['columns'], {'indent': 3}) ~ ']' %} + {%~ endif %} + {%~ if constraint['references'][1] is iterable %} + {%~ set columnsReference = '[' ~ Migration.stringifyList(constraint['references'][1], {'indent': 3}) ~ ']' %} + {%~ else %} + {%~ set columnsReference = '\'' ~ constraint['references'][1] ~ '\'' %} + {%~ endif %} + $table->addForeignKey( + $this->foreignKey({{ columnsList | raw }}) + ->setReferencedTable('{{ constraint['references'][0] }}') + ->setReferencedColumns({{ columnsReference | raw }}) + ->setOnDelete('{{ Migration.formatConstraintAction(constraint['delete']) | raw }}') + ->setOnUpdate('{{ Migration.formatConstraintAction(constraint['update']) | raw }}') + ->setName('{{ constraintName }}') + ); + {%~ endif %} + {%~ endfor %} + {%~ endif %} + {%~ endif %} +{% endif %} + $table->{{ tableMethod }}(){% if tableMethod == 'drop' %}->save(){% endif %}; +{%- endfor -%} diff --git a/templates/bake/element/create-tables.twig b/templates/bake/element/create-tables.twig index b5041d15f..277d38994 100644 --- a/templates/bake/element/create-tables.twig +++ b/templates/bake/element/create-tables.twig @@ -41,14 +41,12 @@ {%~ if constraint['type'] == 'unique' %} {{~ element('Migrations.add-indexes', { indexes: {(name): constraint}, - backend: backend, }) -}} {%~ endif %} {%~ endfor %} {%~ endif %} {{- element('Migrations.add-indexes', { indexes: createData.tables[table].indexes, - backend: backend, }) }} ->create(); {% endfor -%} {% if createData.constraints %} @@ -56,7 +54,6 @@ {{- element('Migrations.add-foreign-keys', { constraints: tableConstraints, table: table, - backend: backend, }) -}} {%~ endfor %} diff --git a/tests/CommandTester.php b/tests/CommandTester.php deleted file mode 100644 index d019a3b80..000000000 --- a/tests/CommandTester.php +++ /dev/null @@ -1,182 +0,0 @@ - - */ - private $inputs = []; - - /** - * @var int - */ - private $statusCode; - - /** - * Constructor. - * - * @param \Symfony\Component\Console\Command\Command $command A Command instance to test - */ - public function __construct(Command $command) - { - $this->command = $command; - } - - /** - * Executes the command. - * - * Available execution options: - * - * * interactive: Sets the input interactive flag - * * decorated: Sets the output decorated flag - * * verbosity: Sets the output verbosity flag - * - * @param array $input An array of command arguments and options - * @param array $options An array of execution options - * @return int The command exit code - */ - public function execute(array $input, array $options = []) - { - // set the command name automatically if the application requires - // this argument and no command name was passed - $application = $this->command->getApplication(); - if ( - !isset($input['command']) - && ($application !== null) - && $application->getDefinition()->hasArgument('command') - ) { - $input += ['command' => $this->command->getName()]; - } - - $this->input = new ArrayInput($input); - if ($this->inputs) { - $this->input->setStream(self::createStream($this->inputs)); - } - - if (isset($options['interactive'])) { - $this->input->setInteractive($options['interactive']); - } - - // This is where the magic does its magic : we use the output object of the command. - $this->output = $this->command->getManager()->getOutput(); - $this->output->setDecorated($options['decorated'] ?? false); - if (isset($options['verbosity'])) { - $this->output->setVerbosity($options['verbosity']); - } - - return $this->statusCode = $this->command->run($this->input, $this->output); - } - - /** - * Gets the display returned by the last execution of the command. - * - * @param bool $normalize Whether to normalize end of lines to \n or not - * @return string The display - */ - public function getDisplay($normalize = false) - { - rewind($this->output->getStream()); - - $display = stream_get_contents($this->output->getStream()); - - if ($normalize) { - $display = str_replace(PHP_EOL, "\n", $display); - } - - return $display; - } - - /** - * Gets the input instance used by the last execution of the command. - * - * @return \Symfony\Component\Console\Input\InputInterface The current input instance - */ - public function getInput() - { - return $this->input; - } - - /** - * Gets the output instance used by the last execution of the command. - * - * @return \Symfony\Component\Console\Output\OutputInterface The current output instance - */ - public function getOutput() - { - return $this->output; - } - - /** - * Gets the status code returned by the last execution of the application. - * - * @return int The status code - */ - public function getStatusCode() - { - return $this->statusCode; - } - - /** - * Sets the user inputs. - * - * @param array $inputs An array of strings representing each input - * passed to the command input stream. - * @return $this - */ - public function setInputs(array $inputs) - { - $this->inputs = $inputs; - - return $this; - } - - /** - * @param array $inputs - * @return resource|false - */ - private static function createStream(array $inputs) - { - $stream = fopen('php://memory', 'r+', false); - - fwrite($stream, implode(PHP_EOL, $inputs)); - rewind($stream); - - return $stream; - } -} diff --git a/tests/ExampleCommand.php b/tests/ExampleCommand.php deleted file mode 100644 index a4f68b605..000000000 --- a/tests/ExampleCommand.php +++ /dev/null @@ -1,20 +0,0 @@ -message. - */ -class RawBufferedOutput extends BufferedOutput -{ - /** - * @param iterable|string $messages - * @param int $options - * @return void - */ - public function writeln($messages, $options = 0): void - { - $this->write($messages, true, $options | self::OUTPUT_RAW); - } -} diff --git a/tests/TestCase/Command/BakeMigrationCommandTest.php b/tests/TestCase/Command/BakeMigrationCommandTest.php index f0d8279da..5e525e50a 100644 --- a/tests/TestCase/Command/BakeMigrationCommandTest.php +++ b/tests/TestCase/Command/BakeMigrationCommandTest.php @@ -43,7 +43,7 @@ public function setUp(): void public function tearDown(): void { parent::tearDown(); - $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_*Users.php'); + $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*Users.php'); if ($files) { foreach ($files as $file) { unlink($file); @@ -130,23 +130,6 @@ public function testCreate($name, $fileSuffix) $this->assertSameAsFile(__FUNCTION__ . $fileSuffix, $result); } - /** - * Test that when the phinx backend is active migrations use - * phinx base classes. - */ - public function testCreatePhinx() - { - Configure::write('Migrations.backend', 'phinx'); - $this->exec('bake migration CreateUsers name --connection test'); - - $file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_CreateUsers.php'); - $filePath = current($file); - - $this->assertExitCode(BaseCommand::CODE_SUCCESS); - $result = file_get_contents($filePath); - $this->assertSameAsFile(__FUNCTION__ . '.php', $result); - } - /** * Tests that baking a migration with the name as another will throw an exception. */ @@ -169,9 +152,8 @@ public function testCreateWithReservedKeyword() $this->assertOutputRegExp('/Wrote.*?PrefixNew\.php/'); } - public function testCreateBuiltinAlias() + public function testCreateBuiltInAlias() { - Configure::write('Migrations.backend', 'builtin'); $this->exec('migrations create CreateUsers --connection test'); $this->assertExitCode(BaseCommand::CODE_SUCCESS); $this->assertOutputRegExp('/Wrote.*?CreateUsers\.php/'); @@ -408,6 +390,62 @@ public function testActionWithoutValidPrefix() $this->assertErrorContains('When applying fields the migration name should start with one of the following prefixes: `Create`, `Drop`, `Add`, `Remove`, `Alter`.'); } + /** + * Test creating migrations with anonymous style + * + * @return void + */ + public function testCreateAnonymousStyle() + { + $this->exec('bake migration CreateUsers name:string --style=anonymous --connection test'); + + $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '????_??_??_??????_CreateUsers.php'); + $this->assertCount(1, $files); + + $filePath = current($files); + $fileName = basename($filePath); + + // Check the file name format + $this->assertMatchesRegularExpression('/^\d{4}_\d{2}_\d{2}_\d{6}_CreateUsers\.php$/', $fileName); + + $this->assertExitCode(BaseCommand::CODE_SUCCESS); + $result = file_get_contents($filePath); + + // Check that it returns an anonymous class directly + $this->assertStringContainsString('return new class extends BaseMigration', $result); + $this->assertStringNotContainsString('class CreateUsers extends', $result); + $this->assertStringNotContainsString('function (int $version)', $result); + } + + /** + * Test creating migrations with anonymous style with configure + * + * @return void + */ + public function testCreateAnonymousStyleWithConfigure() + { + Configure::write('Migrations.style', 'anonymous'); + + $this->exec('bake migration CreateUsers name:string --connection test'); + + $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '????_??_??_??????_CreateUsers.php'); + $this->assertCount(1, $files); + + $filePath = current($files); + $fileName = basename($filePath); + + // Check the file name format + $this->assertMatchesRegularExpression('/^\d{4}_\d{2}_\d{2}_\d{6}_CreateUsers\.php$/', $fileName); + + $this->assertExitCode(BaseCommand::CODE_SUCCESS); + $result = file_get_contents($filePath); + + // Check that it returns an anonymous class directly + $this->assertStringContainsString('return new class extends BaseMigration', $result); + $this->assertStringNotContainsString('class CreateUsers extends', $result); + $this->assertStringNotContainsString('function (int $version)', $result); + } + /** * Test creating migration with references (foreign keys) * diff --git a/tests/TestCase/Command/BakeMigrationDiffCommandTest.php b/tests/TestCase/Command/BakeMigrationDiffCommandTest.php index b486e3d39..2dc4d388a 100644 --- a/tests/TestCase/Command/BakeMigrationDiffCommandTest.php +++ b/tests/TestCase/Command/BakeMigrationDiffCommandTest.php @@ -18,11 +18,17 @@ use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\Database\Driver\Mysql; +use Cake\Database\Driver\Postgres; +use Cake\Database\Driver\Sqlite; +use Cake\Database\Driver\Sqlserver; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\StringCompareTrait; use Cake\Utility\Inflector; +use Exception; +use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; use Migrations\Migrations; use Migrations\Test\TestCase\TestCase; +use Migrations\Util\UtilTrait; use function Cake\Core\env; /** @@ -31,6 +37,7 @@ class BakeMigrationDiffCommandTest extends TestCase { use StringCompareTrait; + use UtilTrait; /** * @var string[] @@ -47,7 +54,38 @@ public function setUp(): void parent::setUp(); $this->generatedFiles = []; - Configure::write('Migrations.backend', 'builtin'); + $this->clearMigrationRecords('test'); + $this->clearMigrationRecords('test', 'Blog'); + + // Clean up any TheDiff migration files from all directories before test starts + $configPath = ROOT . DS . 'config' . DS; + $directories = glob($configPath . '*', GLOB_ONLYDIR) ?: []; + foreach ($directories as $dir) { + // Clean up TheDiff migration files + $migrationFiles = glob($dir . DS . '*TheDiff*.php') ?: []; + foreach ($migrationFiles as $file) { + if (file_exists($file)) { + unlink($file); + } + } + // Clean up Initial migration files + $initialMigrationFiles = glob($dir . DS . '*Initial*.php') ?: []; + foreach ($initialMigrationFiles as $file) { + if (file_exists($file)) { + unlink($file); + } + } + } + + // Clean up test_decimal_types table if it exists + if (env('DB_URL_COMPARE')) { + try { + $connection = ConnectionManager::get('test_comparisons'); + $connection->execute('DROP TABLE IF EXISTS test_decimal_types'); + } catch (Exception $e) { + // Ignore errors if connection doesn't exist yet + } + } } public function tearDown(): void @@ -58,10 +96,30 @@ public function tearDown(): void unlink($file); } } + + // Clean up any TheDiff migration files from all directories + $configPath = ROOT . DS . 'config' . DS; + $directories = glob($configPath . '*', GLOB_ONLYDIR) ?: []; + foreach ($directories as $dir) { + $migrationFiles = glob($dir . DS . '*TheDiff*.php') ?: []; + foreach ($migrationFiles as $file) { + if (file_exists($file)) { + unlink($file); + } + } + $initialMigrationFiles = glob($dir . DS . '*Initial*.php') ?: []; + foreach ($initialMigrationFiles as $file) { + if (file_exists($file)) { + unlink($file); + } + } + } + if (env('DB_URL_COMPARE')) { // Clean up the comparison database each time. Table order is important. + // Include both legacy (phinxlog) and unified (cake_migrations) table names. $connection = ConnectionManager::get('test_comparisons'); - $tables = ['articles', 'categories', 'comments', 'users', 'orphan_table', 'phinxlog']; + $tables = ['articles', 'categories', 'comments', 'users', 'orphan_table', 'phinxlog', 'cake_migrations', 'tags', 'test_blog_phinxlog', 'test_decimal_types']; foreach ($tables as $table) { $connection->execute("DROP TABLE IF EXISTS $table"); } @@ -175,6 +233,9 @@ public function testBakingDiff() { $this->skipIf(!env('DB_URL_COMPARE')); + Configure::write('Migrations.unsigned_primary_keys', true); + Configure::write('Migrations.unsigned_ints', true); + $this->runDiffBakingTest('Default'); } @@ -241,6 +302,17 @@ public function testBakingDiffWithAutoIdIncompatibleUnsignedPrimaryKeys(): void $this->runDiffBakingTest('WithAutoIdIncompatibleUnsignedPrimaryKeys'); } + /** + * Tests baking a diff with decimal column changes + * Regression test for issue #659 + */ + public function testBakingDiffDecimalChange(): void + { + $this->skipIf(!env('DB_URL_COMPARE')); + + $this->runDiffBakingTest('DecimalChange'); + } + /** * Tests that baking a diff with --plugin option only includes tables with Table classes */ @@ -331,13 +403,24 @@ protected function runDiffBakingTest(string $scenario): void { $this->skipIf(!env('DB_URL_COMPARE')); + // Detect database type from connection if DB env is not set + $db = env('DB') ?: $this->getDbType(); + $diffConfigFolder = Plugin::path('Migrations') . 'tests' . DS . 'comparisons' . DS . 'Diff' . DS . lcfirst($scenario) . DS; - $diffMigrationsPath = $diffConfigFolder . 'the_diff_' . Inflector::underscore($scenario) . '_' . env('DB') . '.php'; - $diffDumpPath = $diffConfigFolder . 'schema-dump-test_comparisons_' . env('DB') . '.lock'; + + // DecimalChange uses 'initial_' prefix to avoid class name conflicts + $prefix = $scenario === 'DecimalChange' ? 'initial_' : 'the_diff_'; + $classPrefix = $scenario === 'DecimalChange' ? 'Initial' : 'TheDiff'; + + $diffMigrationsPath = $diffConfigFolder . $prefix . Inflector::underscore($scenario) . '_' . $db . '.php'; + $diffDumpPath = $diffConfigFolder . 'schema-dump-test_comparisons_' . $db . '.lock'; $destinationConfigDir = ROOT . DS . 'config' . DS . "MigrationsDiff{$scenario}" . DS; - $destination = $destinationConfigDir . "20160415220805_TheDiff{$scenario}" . ucfirst(env('DB')) . '.php'; - $destinationDumpPath = $destinationConfigDir . 'schema-dump-test_comparisons_' . env('DB') . '.lock'; + if (!is_dir($destinationConfigDir)) { + mkdir($destinationConfigDir, 0777, true); + } + $destination = $destinationConfigDir . "20160415220805_{$classPrefix}{$scenario}" . ucfirst($db) . '.php'; + $destinationDumpPath = $destinationConfigDir . 'schema-dump-test_comparisons_' . $db . '.lock'; copy($diffMigrationsPath, $destination); $this->generatedFiles = [ @@ -348,15 +431,19 @@ protected function runDiffBakingTest(string $scenario): void $migrations = $this->getMigrations("MigrationsDiff$scenario"); $migrations->migrate(); - unlink($destination); copy($diffDumpPath, $destinationDumpPath); $connection = ConnectionManager::get('test_comparisons'); + $schemaTable = $this->getPhinxTable(null, $connection); $connection->deleteQuery() - ->delete('phinxlog') + ->delete($schemaTable) ->where(['version' => 20160415220805]) ->execute(); + // Delete the migration file too - checkSync() compares the last file version + // against the last migrated version, so having an unmigrated file would fail + unlink($destination); + $this->_compareBasePath = Plugin::path('Migrations') . 'tests' . DS . 'comparisons' . DS . 'Diff' . DS . lcfirst($scenario) . DS; $bakeName = $this->getBakeName("TheDiff{$scenario}"); @@ -375,19 +462,48 @@ protected function runDiffBakingTest(string $scenario): void rename($destinationConfigDir . $generatedMigration, $destination); $versionParts = explode('_', $generatedMigration); + $columns = ['version', 'migration_name', 'start_time', 'end_time']; + $values = [ + 'version' => 20160415220805, + 'migration_name' => $versionParts[1], + 'start_time' => '2016-05-22 16:51:46', + 'end_time' => '2016-05-22 16:51:46', + ]; + if ($schemaTable === UnifiedMigrationsTableStorage::TABLE_NAME) { + $columns[] = 'plugin'; + $values['plugin'] = null; + } $connection->insertQuery() - ->insert(['version', 'migration_name', 'start_time', 'end_time']) - ->into('phinxlog') - ->values([ - 'version' => 20160415220805, - 'migration_name' => $versionParts[1], - 'start_time' => '2016-05-22 16:51:46', - 'end_time' => '2016-05-22 16:51:46', - ]) + ->insert($columns) + ->into($schemaTable) + ->values($values) ->execute(); $this->getMigrations("MigrationsDiff{$scenario}")->rollback(['target' => 'all']); } + /** + * Detect database type from connection + * + * @return string Database type (mysql, pgsql, sqlite, sqlserver) + */ + protected function getDbType(): string + { + $connection = ConnectionManager::get('test_comparisons'); + $driver = $connection->getDriver(); + + if ($driver instanceof Mysql) { + return 'mysql'; + } elseif ($driver instanceof Postgres) { + return 'pgsql'; + } elseif ($driver instanceof Sqlite) { + return 'sqlite'; + } elseif ($driver instanceof Sqlserver) { + return 'sqlserver'; + } + + return 'mysql'; // Default fallback + } + /** * Get the baked filename based on the current db environment * @@ -396,7 +512,11 @@ protected function runDiffBakingTest(string $scenario): void */ public function getBakeName($name) { - $name .= ucfirst(getenv('DB')); + $db = getenv('DB'); + if (!$db) { + $db = $this->getDbType(); + } + $name .= ucfirst($db); return $name; } @@ -419,6 +539,21 @@ protected function getMigrations($source = 'MigrationsDiff') return $migrations; } + /** + * Override to normalize table names for comparison + * + * @param string $path Path to comparison file + * @param string $result Actual result + * @return void + */ + public function assertSameAsFile(string $path, string $result): void + { + // Normalize unified table name to legacy for comparison + $result = str_replace("'cake_migrations'", "'phinxlog'", $result); + + parent::assertSameAsFile($path, $result); + } + /** * Assert that the $result matches the content of the baked file * @@ -429,6 +564,9 @@ protected function getMigrations($source = 'MigrationsDiff') public function assertCorrectSnapshot($bakeName, $result) { $dbenv = getenv('DB'); + if (!$dbenv) { + $dbenv = $this->getDbType(); + } $bakeName = Inflector::underscore($bakeName); if (file_exists($this->_compareBasePath . $dbenv . DS . $bakeName . '.php')) { $this->assertSameAsFile($dbenv . DS . $bakeName . '.php', $result); diff --git a/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php b/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php index 71c1af8f1..d90980c7c 100644 --- a/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php +++ b/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php @@ -61,7 +61,6 @@ public function setUp(): void $this->migrationPath = ROOT . DS . 'config' . DS . 'Migrations' . DS; $this->generatedFiles = []; - Configure::write('Migrations.backend', 'builtin'); } /** @@ -169,7 +168,7 @@ public function testSnapshotPostgresTimestampTzColumn(): void } /** - * Test baking a snapshot with the phinx auto-id feature disabled + * Test baking a snapshot with the auto-id feature disabled * * @return void */ @@ -178,6 +177,16 @@ public function testAutoIdDisabledSnapshot() $this->runSnapshotTest('AutoIdDisabled', '--disable-autoid'); } + /** + * Test baking a snapshot with the change() method + * + * @return void + */ + public function testSnapshotWithChange() + { + $this->runSnapshotTest('WithChange', '--change'); + } + /** * Tests that baking a diff with signed primary keys is auto-id compatible * when `Migrations.unsigned_primary_keys` is disabled. @@ -316,6 +325,37 @@ public function getBakeName($name) return $name; } + /** + * Override to normalize collation names for MySQL version compatibility + * + * @param string $path Path to comparison file + * @param string $result Actual result + * @return void + */ + public function assertSameAsFile(string $path, string $result): void + { + if (!file_exists($path)) { + $path = $this->_compareBasePath . $path; + } + + $this->_updateComparisons ??= (bool)env('UPDATE_TEST_COMPARISON_FILES'); + + if ($this->_updateComparisons) { + file_put_contents($path, $result); + } + + $expected = file_get_contents($path); + + // Normalize utf8mb3 to utf8 for MySQL 8.0.30+ compatibility + $expected = str_replace('utf8mb3_', 'utf8_', $expected); + $result = str_replace('utf8mb3_', 'utf8_', $result); + + // Normalize unified table name to legacy for comparison + $result = str_replace("'cake_migrations'", "'phinxlog'", $result); + + $this->assertTextEquals($expected, $result, 'Content does not match file ' . $path); + } + /** * Assert that the $result matches the content of the baked file * diff --git a/tests/TestCase/Command/BakeSeedCommandTest.php b/tests/TestCase/Command/BakeSeedCommandTest.php index dd286bf9c..a44d2c514 100644 --- a/tests/TestCase/Command/BakeSeedCommandTest.php +++ b/tests/TestCase/Command/BakeSeedCommandTest.php @@ -52,22 +52,6 @@ public function setUp(): void $this->_compareBasePath = Plugin::path('Migrations') . 'tests' . DS . 'comparisons' . DS . 'Seeds' . DS; } - /** - * Test empty migration with phinx base class. - * - * @return void - */ - public function testBasicBakingPhinx() - { - Configure::write('Migrations.backend', 'phinx'); - $this->generatedFile = ROOT . DS . 'config/Seeds/ArticlesSeed.php'; - $this->exec('bake seed Articles --connection test'); - - $this->assertExitCode(BaseCommand::CODE_SUCCESS); - $result = file_get_contents($this->generatedFile); - $this->assertSameAsFile(__FUNCTION__ . '.php', $result); - } - /** * Test empty migration. * @@ -177,4 +161,24 @@ public function testPrettifyArray() $result = file_get_contents($this->generatedFile); $this->assertSameAsFile(__FUNCTION__ . '.php', $result); } + + /** + * Test baking anonymous seed with Configure + * + * @return void + */ + public function testAnonymousStyleWithConfigure() + { + Configure::write('Migrations.style', 'anonymous'); + + $this->generatedFile = ROOT . DS . 'config/Seeds/ArticlesSeed.php'; + $this->exec('bake seed Articles --connection test'); + + $this->assertExitCode(BaseCommand::CODE_SUCCESS); + $result = file_get_contents($this->generatedFile); + + // Check that it returns an anonymous class + $this->assertStringContainsString('return new class extends BaseSeed', $result); + $this->assertStringNotContainsString('class ArticlesSeed extends', $result); + } } diff --git a/tests/TestCase/Command/CompletionTest.php b/tests/TestCase/Command/CompletionTest.php index b30b23040..b05f0a4da 100644 --- a/tests/TestCase/Command/CompletionTest.php +++ b/tests/TestCase/Command/CompletionTest.php @@ -14,6 +14,7 @@ namespace Migrations\Test\TestCase\Command; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; +use Cake\Core\Configure; use Cake\TestSuite\TestCase; /** @@ -43,9 +44,16 @@ public function tearDown(): void public function testMigrationsSubcommands() { $this->exec('completion subcommands migrations.migrations'); - $expected = [ - 'dump mark_migrated migrate rollback seed status', - ]; + // Upgrade command is hidden when legacyTables is disabled + if (Configure::read('Migrations.legacyTables') === false) { + $expected = [ + 'dump mark_migrated migrate rollback status', + ]; + } else { + $expected = [ + 'dump mark_migrated migrate rollback status upgrade', + ]; + } $actual = $this->_out->messages(); $this->assertEquals($expected, $actual); } diff --git a/tests/TestCase/Command/DumpCommandTest.php b/tests/TestCase/Command/DumpCommandTest.php index 45c07b040..7dba4d8bc 100644 --- a/tests/TestCase/Command/DumpCommandTest.php +++ b/tests/TestCase/Command/DumpCommandTest.php @@ -3,20 +3,16 @@ namespace Migrations\Test\TestCase\Command; -use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; -use Cake\Core\Configure; use Cake\Core\Exception\MissingPluginException; use Cake\Core\Plugin; use Cake\Database\Connection; use Cake\Database\Schema\TableSchema; use Cake\Datasource\ConnectionManager; -use Cake\TestSuite\TestCase; +use Migrations\Test\TestCase\TestCase; use RuntimeException; class DumpCommandTest extends TestCase { - use ConsoleIntegrationTestTrait; - protected Connection $connection; protected string $_compareBasePath; protected string $dumpFile; @@ -24,7 +20,6 @@ class DumpCommandTest extends TestCase public function setUp(): void { parent::setUp(); - Configure::write('Migrations.backend', 'builtin'); /** @var \Cake\Database\Connection $this->connection */ $this->connection = ConnectionManager::get('test'); @@ -32,6 +27,7 @@ public function setUp(): void $this->connection->execute('DROP TABLE IF EXISTS letters'); $this->connection->execute('DROP TABLE IF EXISTS parts'); $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); + $this->connection->execute('DROP TABLE IF EXISTS cake_migrations'); $this->dumpFile = ROOT . DS . 'config/TestsMigrations/schema-dump-test.lock'; } @@ -44,6 +40,7 @@ public function tearDown(): void $this->connection->execute('DROP TABLE IF EXISTS letters'); $this->connection->execute('DROP TABLE IF EXISTS parts'); $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); + $this->connection->execute('DROP TABLE IF EXISTS cake_migrations'); if (file_exists($this->dumpFile)) { unlink($this->dumpFile); } diff --git a/tests/TestCase/Command/EntryCommandTest.php b/tests/TestCase/Command/EntryCommandTest.php index d6d8f03de..112a78454 100644 --- a/tests/TestCase/Command/EntryCommandTest.php +++ b/tests/TestCase/Command/EntryCommandTest.php @@ -17,7 +17,6 @@ namespace Migrations\Test\TestCase\Command; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; -use Cake\Core\Configure; use Cake\TestSuite\TestCase; /** @@ -30,8 +29,6 @@ class EntryCommandTest extends TestCase public function setUp(): void { parent::setUp(); - - Configure::write('Migrations.backend', 'builtin'); } /** @@ -44,7 +41,6 @@ public function testExecuteHelp() $this->exec('migrations --help'); $this->assertExitSuccess(); - $this->assertOutputContains('Using builtin backend'); $this->assertOutputContains('migrations migrate'); $this->assertOutputContains('migrations status'); $this->assertOutputContains('migrations rollback'); diff --git a/tests/TestCase/Command/MarkMigratedTest.php b/tests/TestCase/Command/MarkMigratedTest.php index e1c94ec32..669f19327 100644 --- a/tests/TestCase/Command/MarkMigratedTest.php +++ b/tests/TestCase/Command/MarkMigratedTest.php @@ -13,19 +13,15 @@ */ namespace Migrations\Test\TestCase\Command; -use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; -use Cake\Core\Configure; use Cake\Core\Exception\MissingPluginException; use Cake\Datasource\ConnectionManager; -use Cake\TestSuite\TestCase; +use Migrations\Test\TestCase\TestCase; /** * MarkMigratedTest class */ class MarkMigratedTest extends TestCase { - use ConsoleIntegrationTestTrait; - /** * Instance of a Cake Connection object * @@ -41,11 +37,12 @@ class MarkMigratedTest extends TestCase public function setUp(): void { parent::setUp(); - Configure::write('Migrations.backend', 'builtin'); $this->connection = ConnectionManager::get('test'); + // Drop both legacy and unified tables $this->connection->execute('DROP TABLE IF EXISTS migrator_phinxlog'); $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); + $this->connection->execute('DROP TABLE IF EXISTS cake_migrations'); $this->connection->execute('DROP TABLE IF EXISTS numbers'); } @@ -59,6 +56,7 @@ public function tearDown(): void parent::tearDown(); $this->connection->execute('DROP TABLE IF EXISTS migrator_phinxlog'); $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); + $this->connection->execute('DROP TABLE IF EXISTS cake_migrations'); $this->connection->execute('DROP TABLE IF EXISTS numbers'); } @@ -82,7 +80,7 @@ public function testExecute() 'Migration `20150704160200` successfully marked migrated !', ); - $result = $this->connection->selectQuery()->select(['*'])->from('phinxlog')->execute()->fetchAll('assoc'); + $result = $this->connection->selectQuery()->select(['*'])->from($this->getMigrationsTableName())->execute()->fetchAll('assoc'); $this->assertEquals('20150704160200', $result[0]['version']); $this->assertEquals('20150724233100', $result[1]['version']); $this->assertEquals('20150826191400', $result[2]['version']); @@ -100,7 +98,7 @@ public function testExecute() 'Skipping migration `20150826191400` (already migrated).', ); - $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); + $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from($this->getMigrationsTableName())->execute(); $this->assertEquals(4, $result->fetchColumn(0)); } @@ -115,7 +113,7 @@ public function testExecuteTarget() $result = $this->connection->selectQuery() ->select(['*']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute() ->fetchAll('assoc'); $this->assertEquals('20150704160200', $result[0]['version']); @@ -135,7 +133,7 @@ public function testExecuteTarget() $result = $this->connection->selectQuery() ->select(['*']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute() ->fetchAll('assoc'); $this->assertEquals('20150704160200', $result[0]['version']); @@ -144,7 +142,7 @@ public function testExecuteTarget() $result = $this->connection->selectQuery() ->select(['COUNT(*)']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute(); $this->assertEquals(3, $result->fetchColumn(0)); } @@ -169,7 +167,7 @@ public function testExecuteTargetWithExclude() $result = $this->connection->selectQuery() ->select(['*']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute() ->fetchAll('assoc'); $this->assertEquals('20150704160200', $result[0]['version']); @@ -185,7 +183,7 @@ public function testExecuteTargetWithExclude() $result = $this->connection->selectQuery() ->select(['*']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute() ->fetchAll('assoc'); $this->assertEquals('20150704160200', $result[0]['version']); @@ -193,7 +191,7 @@ public function testExecuteTargetWithExclude() $result = $this->connection->selectQuery() ->select(['COUNT(*)']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute(); $this->assertEquals(2, $result->fetchColumn(0)); } @@ -219,7 +217,7 @@ public function testExecuteTargetWithOnly() $result = $this->connection->selectQuery() ->select(['*']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute() ->fetchAll('assoc'); $this->assertEquals('20150724233100', $result[0]['version']); @@ -232,14 +230,14 @@ public function testExecuteTargetWithOnly() $result = $this->connection->selectQuery() ->select(['*']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute() ->fetchAll('assoc'); $this->assertEquals('20150826191400', $result[1]['version']); $this->assertEquals('20150724233100', $result[0]['version']); $result = $this->connection->selectQuery() ->select(['COUNT(*)']) - ->from('phinxlog') + ->from($this->getMigrationsTableName()) ->execute(); $this->assertEquals(2, $result->fetchColumn(0)); } @@ -308,6 +306,6 @@ public function testExecutePlugin(): void /** @var \Cake\Database\Connection $connection */ $connection = ConnectionManager::get('test'); $tables = $connection->getSchemaCollection()->listTables(); - $this->assertContains('migrator_phinxlog', $tables); + $this->assertContains($this->getMigrationsTableName('Migrator'), $tables); } } diff --git a/tests/TestCase/Command/MigrateCommandTest.php b/tests/TestCase/Command/MigrateCommandTest.php index a7040aaee..8b063afc6 100644 --- a/tests/TestCase/Command/MigrateCommandTest.php +++ b/tests/TestCase/Command/MigrateCommandTest.php @@ -3,37 +3,22 @@ namespace Migrations\Test\TestCase\Command; -use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; -use Cake\Core\Configure; use Cake\Core\Exception\MissingPluginException; -use Cake\Database\Exception\DatabaseException; use Cake\Datasource\ConnectionManager; use Cake\Event\EventInterface; use Cake\Event\EventManager; -use Cake\TestSuite\TestCase; +use Migrations\Test\TestCase\TestCase; class MigrateCommandTest extends TestCase { - use ConsoleIntegrationTestTrait; - protected array $createdFiles = []; public function setUp(): void { parent::setUp(); - Configure::write('Migrations.backend', 'builtin'); - - try { - $table = $this->fetchTable('Phinxlog'); - $table->deleteAll('1=1'); - } catch (DatabaseException $e) { - } - try { - $table = $this->fetchTable('MigratorPhinxlog'); - $table->deleteAll('1=1'); - } catch (DatabaseException $e) { - } + $this->clearMigrationRecords('test'); + $this->clearMigrationRecords('test', 'Migrator'); } public function tearDown(): void @@ -64,8 +49,8 @@ public function testMigrateNoMigrationSource(): void $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(0, $table->find()->all()->toArray()); + $count = $this->getMigrationRecordCount('test'); + $this->assertEquals(0, $count); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->assertFileDoesNotExist($dumpFile); @@ -95,8 +80,7 @@ public function testMigrateSourceDefault(): void $this->assertOutputContains('MarkMigratedTest: migrated'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(2, $table->find()->all()->toArray()); + $this->assertEquals(2, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->createdFiles[] = $dumpFile; @@ -104,7 +88,7 @@ public function testMigrateSourceDefault(): void } /** - * Integration test for BaseMigration with built-in backend. + * Integration test for BaseMigration. */ public function testMigrateBaseMigration(): void { @@ -117,8 +101,7 @@ public function testMigrateBaseMigration(): void $this->assertOutputContains('hasTable=1'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(1, $table->find()->all()->toArray()); + $this->assertEquals(1, $this->getMigrationRecordCount('test')); } /** @@ -134,8 +117,7 @@ public function testMigrateWithSourceMigration(): void $this->assertOutputContains('ShouldNotExecuteMigration: skipped '); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(1, $table->find()->all()->toArray()); + $this->assertEquals(1, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->createdFiles[] = $dumpFile; @@ -155,8 +137,7 @@ public function testMigrateDryRun() $this->assertOutputContains('MarkMigratedTest: migrated'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(0, $table->find()->all()->toArray()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->assertFileDoesNotExist($dumpFile); @@ -174,8 +155,7 @@ public function testMigrateDate() $this->assertOutputContains('MarkMigratedTest: migrated'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(1, $table->find()->all()->toArray()); + $this->assertEquals(1, $this->getMigrationRecordCount('test')); $this->assertFileExists($migrationPath . DS . 'schema-dump-test.lock'); } @@ -192,8 +172,7 @@ public function testMigrateDateNotFound() $this->assertOutputContains('No migrations to run'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(0, $table->find()->all()->toArray()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); $this->assertFileExists($migrationPath . DS . 'schema-dump-test.lock'); } @@ -210,8 +189,7 @@ public function testMigrateTarget() $this->assertOutputNotContains('MarkMigratedTestSecond'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(1, $table->find()->all()->toArray()); + $this->assertEquals(1, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->createdFiles[] = $dumpFile; @@ -229,8 +207,7 @@ public function testMigrateTargetNotFound() $this->assertOutputContains('warning 99 is not a valid version'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(0, $table->find()->all()->toArray()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->createdFiles[] = $dumpFile; @@ -248,8 +225,7 @@ public function testMigrateFakeAll() $this->assertOutputContains('MarkMigratedTestSecond: migrated'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(2, $table->find()->all()->toArray()); + $this->assertEquals(2, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->createdFiles[] = $dumpFile; @@ -267,8 +243,7 @@ public function testMigratePlugin() $this->assertOutputContains('All Done'); // Migration tracking table is plugin specific - $table = $this->fetchTable('MigratorPhinxlog'); - $this->assertCount(1, $table->find()->all()->toArray()); + $this->assertEquals(1, $this->getMigrationRecordCount('test', 'Migrator')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->createdFiles[] = $dumpFile; @@ -340,7 +315,6 @@ public function testBeforeMigrateEventAbort(): void // Only one event was fired $this->assertSame(['Migration.beforeMigrate'], $fired); - $table = $this->fetchTable('Phinxlog'); - $this->assertEquals(0, $table->find()->count()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); } } diff --git a/tests/TestCase/Command/MigrationCommandTest.php b/tests/TestCase/Command/MigrationCommandTest.php deleted file mode 100644 index 41a952bb9..000000000 --- a/tests/TestCase/Command/MigrationCommandTest.php +++ /dev/null @@ -1,146 +0,0 @@ -command); - } - - /** - * Test that migrating without the `--no-lock` option will dispatch a dump shell - * - * @return void - */ - public function testMigrateWithLock() - { - $argv = [ - '-c', - 'test', - ]; - - $this->command = $this->getMockCommand('MigrationsMigrateCommand'); - - $this->command->expects($this->once()) - ->method('executeCommand'); - - $this->command->run($argv, $this->getMockIo()); - } - - /** - * Test that migrating with the `--no-lock` option will not dispatch a dump shell - * - * @return void - */ - public function testMigrateWithNoLock() - { - $argv = [ - '-c', - 'test', - '--no-lock', - ]; - - $this->command = $this->getMockCommand('MigrationsMigrateCommand'); - - $this->command->expects($this->never()) - ->method('executeCommand'); - - $this->command->run($argv, $this->getMockIo()); - } - - /** - * Test that rolling back without the `--no-lock` option will dispatch a dump shell - * - * @return void - */ - public function testRollbackWithLock() - { - $argv = [ - '-c', - 'test', - ]; - - $this->command = $this->getMockCommand('MigrationsRollbackCommand'); - - $this->command->expects($this->once()) - ->method('executeCommand'); - - $this->command->run($argv, $this->getMockIo()); - } - - /** - * Test that rolling back with the `--no-lock` option will not dispatch a dump shell - * - * @return void - */ - public function testRollbackWithNoLock() - { - $argv = [ - '-c', - 'test', - '--no-lock', - ]; - - $this->command = $this->getMockCommand('MigrationsRollbackCommand'); - - $this->command->expects($this->never()) - ->method('executeCommand'); - - $this->command->run($argv, $this->getMockIo()); - } - - protected function getMockIo() - { - $in = new StubConsoleInput([]); - $output = new StubConsoleOutput(); - $io = $this->getMockBuilder(ConsoleIo::class) - ->setConstructorArgs([$output, $output, $in]) - ->getMock(); - - return $io; - } - - protected function getMockCommand($command) - { - $mockedMethods = [ - 'executeCommand', - 'getApp', - 'getOutput', - ]; - - $mock = $this->getMockBuilder('Migrations\Command\\' . $command) - ->onlyMethods($mockedMethods) - ->getMock(); - - $mock->expects($this->any()) - ->method('getOutput') - ->willReturn(new NullOutput()); - - $mock->expects($this->any()) - ->method('getApp') - ->willReturn(new MigrationsDispatcher(PHINX_VERSION)); - - return $mock; - } -} diff --git a/tests/TestCase/Command/Phinx/CacheBuildTest.php b/tests/TestCase/Command/Phinx/CacheBuildTest.php deleted file mode 100644 index e02623ca5..000000000 --- a/tests/TestCase/Command/Phinx/CacheBuildTest.php +++ /dev/null @@ -1,118 +0,0 @@ -connection = ConnectionManager::get('test'); - $this->connection->cacheMetadata(true); - $this->connection->execute('DROP TABLE IF EXISTS blog'); - $this->connection->execute('CREATE TABLE blog (id int NOT NULL, title varchar(200) NOT NULL)'); - $application = new MigrationsDispatcher('testing'); - $this->command = $application->find('orm-cache-build'); - $this->streamOutput = new StreamOutput(fopen('php://memory', 'w', false)); - $this->_compareBasePath = Plugin::path('Migrations') . 'tests' . DS . 'comparisons' . DS . 'Cache' . DS; - } - - /** - * tearDown method - * - * @return void - */ - public function tearDown(): void - { - parent::tearDown(); - - Cache::disable(); - $this->connection->cacheMetadata(false); - $this->connection->execute('DROP TABLE IF EXISTS blog'); - } - - /** - * Test executing the `create` command will generate the desired file. - * - * @return void - */ - public function testExecute() - { - $params = [ - '--connection' => 'test', - ]; - $commandTester = $this->getCommandTester($params); - - $commandTester->execute([ - 'command' => $this->command->getName(), - '--connection' => 'test', - ]); - - $this->assertNotFalse(Cache::read('test_blog', '_cake_model_')); - } - - /** - * Gets a pre-configured of a CommandTester object that is initialized for each - * test methods. This is needed in order to define the same PDO connection resource - * between every objects needed during the tests. - * This is mandatory for the SQLite database vendor, so phinx objects interacting - * with the database have the same connection resource as CakePHP objects. - * - * @param array $params - * @return \Migrations\Test\CommandTester - */ - protected function getCommandTester($params) - { - if (!$this->connection->getDriver()->isConnected()) { - $this->connection->getDriver()->connect(); - } - - //$input = new ArrayInput($params, $this->command->getDefinition()); - $commandTester = new CommandTester($this->command); - - return $commandTester; - } -} diff --git a/tests/TestCase/Command/Phinx/CacheClearTest.php b/tests/TestCase/Command/Phinx/CacheClearTest.php deleted file mode 100644 index 7b749fb56..000000000 --- a/tests/TestCase/Command/Phinx/CacheClearTest.php +++ /dev/null @@ -1,111 +0,0 @@ -connection = ConnectionManager::get('test'); - $this->connection->cacheMetadata(true); - $this->connection->execute('DROP TABLE IF EXISTS blog'); - $this->connection->execute('CREATE TABLE blog (id int NOT NULL, title varchar(200) NOT NULL)'); - $application = new MigrationsDispatcher('testing'); - $this->command = $application->find('orm-cache-clear'); - $this->streamOutput = new StreamOutput(fopen('php://memory', 'w', false)); - } - - /** - * tearDown method - * - * @return void - */ - public function tearDown(): void - { - parent::tearDown(); - Cache::disable(); - $this->connection->cacheMetadata(false); - $this->connection->execute('DROP TABLE IF EXISTS blog'); - } - - /** - * Test executing the `create` command will generate the desired file. - * - * @return void - */ - public function testExecute() - { - $params = [ - '--connection' => 'test', - ]; - $commandTester = $this->getCommandTester($params); - - $commandTester->execute([ - 'command' => $this->command->getName(), - '--connection' => 'test', - ]); - - $this->assertNull(Cache::read('test_blog', '_cake_model_')); - } - - /** - * Gets a pre-configured of a CommandTester object that is initialized for each - * test methods. This is needed in order to define the same PDO connection resource - * between every objects needed during the tests. - * This is mandatory for the SQLite database vendor, so phinx objects interacting - * with the database have the same connection resource as CakePHP objects. - * - * @param array $params - * @return \Migrations\Test\CommandTester - */ - protected function getCommandTester($params) - { - if (!$this->connection->getDriver()->isConnected()) { - $this->connection->getDriver()->connect(); - } - - //$input = new ArrayInput($params, $this->command->getDefinition()); - $commandTester = new CommandTester($this->command); - - return $commandTester; - } -} diff --git a/tests/TestCase/Command/Phinx/CreateTest.php b/tests/TestCase/Command/Phinx/CreateTest.php deleted file mode 100644 index 67d6062cf..000000000 --- a/tests/TestCase/Command/Phinx/CreateTest.php +++ /dev/null @@ -1,139 +0,0 @@ -connection = ConnectionManager::get('test'); - $application = new MigrationsDispatcher('testing'); - $this->command = $application->find('create'); - $this->streamOutput = new StreamOutput(fopen('php://memory', 'w', false)); - $this->_compareBasePath = Plugin::path('Migrations') . 'tests' . DS . 'comparisons' . DS . 'Create' . DS; - $this->generatedFiles = []; - } - - /** - * tearDown method - * - * @return void - */ - public function tearDown(): void - { - parent::tearDown(); - foreach ($this->generatedFiles as $file) { - if (file_exists($file)) { - unlink($file); - } - } - } - - /** - * Test executing the `create` command will generate the desired file. - * - * @return void - */ - public function testExecute() - { - $params = [ - '--connection' => 'test', - '--source' => 'Create', - 'name' => 'TestCreateChange', - ]; - $commandTester = $this->getCommandTester($params); - - $commandTester->execute([ - 'command' => $this->command->getName(), - 'name' => 'TestCreateChange', - '--connection' => 'test', - ]); - - $files = glob(ROOT . DS . 'config' . DS . 'Create' . DS . '*_TestCreateChange*.php'); - $this->generatedFiles = $files; - $this->assertNotEmpty($files); - - $file = current($files); - $this->assertSameAsFile('TestCreateChange.php', file_get_contents($file)); - } - - /** - * Gets a pre-configured of a CommandTester object that is initialized for each - * test methods. This is needed in order to define the same PDO connection resource - * between every objects needed during the tests. - * This is mandatory for the SQLite database vendor, so phinx objects interacting - * with the database have the same connection resource as CakePHP objects. - * - * @param array $params - * @return \Migrations\Test\CommandTester - */ - protected function getCommandTester($params) - { - if (!$this->connection->getDriver()->isConnected()) { - $this->connection->getDriver()->connect(); - } - - $input = new ArrayInput($params, $this->command->getDefinition()); - $this->command->setInput($input); - $manager = new CakeManager($this->command->getConfig(), $input, $this->streamOutput); - $adapter = $manager->getEnvironment('default')->getAdapter(); - while ($adapter instanceof WrapperInterface) { - $adapter = $adapter->getAdapter(); - } - $adapter->setConnection($this->getDriverConnection($this->connection->getDriver())); - $this->command->setManager($manager); - $commandTester = new CommandTester($this->command); - - return $commandTester; - } -} diff --git a/tests/TestCase/Command/Phinx/DumpTest.php b/tests/TestCase/Command/Phinx/DumpTest.php deleted file mode 100644 index 847fa58ba..000000000 --- a/tests/TestCase/Command/Phinx/DumpTest.php +++ /dev/null @@ -1,225 +0,0 @@ -connection = ConnectionManager::get('test'); - $this->connection->getDriver()->connect(); - $this->pdo = $this->getDriverConnection($this->connection->getDriver()); - - $application = new MigrationsDispatcher('testing'); - $this->command = $application->find('dump'); - $this->streamOutput = new StreamOutput(fopen('php://memory', 'w', false)); - $this->_compareBasePath = Plugin::path('Migrations') . 'tests' . DS . 'comparisons' . DS . 'Migration' . DS; - - $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); - $this->connection->execute('DROP TABLE IF EXISTS numbers'); - $this->connection->execute('DROP TABLE IF EXISTS letters'); - $this->connection->execute('DROP TABLE IF EXISTS parts'); - $this->connection->execute('DROP TABLE IF EXISTS stores'); - $this->dumpfile = ROOT . DS . 'config/TestsMigrations/schema-dump-test.lock'; - } - - /** - * tearDown method - * - * @return void - */ - public function tearDown(): void - { - parent::tearDown(); - $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); - $this->connection->execute('DROP TABLE IF EXISTS numbers'); - $this->connection->execute('DROP TABLE IF EXISTS letters'); - $this->connection->execute('DROP TABLE IF EXISTS parts'); - $this->connection->execute('DROP TABLE IF EXISTS stores'); - } - - /** - * Test executing "dump" with tables in the database - * - * @return void - */ - public function testExecuteTables() - { - $params = [ - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]; - $commandTester = $this->getCommandTester($params); - $migrations = $this->getMigrations(); - $migrations->migrate(); - - $commandTester->execute([ - 'command' => $this->command->getName(), - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertFileExists($this->dumpfile); - $generatedDump = unserialize(file_get_contents($this->dumpfile)); - - $this->assertArrayHasKey('letters', $generatedDump); - $this->assertArrayHasKey('numbers', $generatedDump); - $this->assertInstanceOf(TableSchema::class, $generatedDump['numbers']); - $this->assertInstanceOf(TableSchema::class, $generatedDump['letters']); - $this->assertEquals(['id', 'number', 'radix'], $generatedDump['numbers']->columns()); - $this->assertEquals(['id', 'letter'], $generatedDump['letters']->columns()); - - $migrations->rollback(['target' => 'all']); - } - - /** - * Gets a pre-configured of a CommandTester object that is initialized for each - * test methods. This is needed in order to define the same PDO connection resource - * between every objects needed during the tests. - * This is mandatory for the SQLite database vendor, so phinx objects interacting - * with the database have the same connection resource as CakePHP objects. - * - * @param array $params - * @return \Migrations\Test\CommandTester - */ - protected function getCommandTester($params) - { - $input = new ArrayInput($params, $this->command->getDefinition()); - $this->command->setInput($input); - $manager = new CakeManager($this->command->getConfig(), $input, $this->streamOutput); - - $adapter = $manager - ->getEnvironment('default') - ->getAdapter(); - while ($adapter instanceof WrapperInterface) { - $adapter = $adapter->getAdapter(); - } - $adapter->setConnection($this->pdo); - - $this->command->setManager($manager); - $commandTester = new CommandTester($this->command); - - return $commandTester; - } - - /** - * Gets a Migrations object in order to easily create and drop tables during the - * tests - * - * @return \Migrations\Migrations - */ - protected function getMigrations() - { - $params = [ - 'connection' => 'test', - 'source' => 'TestsMigrations', - ]; - $migrations = new Migrations($params); - $adapter = $migrations - ->getManager($this->command->getConfig()) - ->getEnvironment('default') - ->getAdapter(); - - while ($adapter instanceof WrapperInterface) { - $adapter = $adapter->getAdapter(); - } - - $adapter->setConnection($this->pdo); - - $tables = (new Collection($this->connection))->listTables(); - if (in_array('phinxlog', $tables)) { - $ormTable = $this->getTableLocator()->get('phinxlog', ['connection' => $this->connection]); - $query = $this->connection->getDriver()->schemaDialect()->truncateTableSql($ormTable->getSchema()); - foreach ($query as $stmt) { - $this->connection->execute($stmt); - } - } - - return $migrations; - } - - /** - * Extract the content that was stored in self::$streamOutput. - * - * @return string - */ - protected function getDisplayFromOutput() - { - rewind($this->streamOutput->getStream()); - $display = stream_get_contents($this->streamOutput->getStream()); - - return str_replace(PHP_EOL, "\n", $display); - } -} diff --git a/tests/TestCase/Command/Phinx/MarkMigratedTest.php b/tests/TestCase/Command/Phinx/MarkMigratedTest.php deleted file mode 100644 index 05590721c..000000000 --- a/tests/TestCase/Command/Phinx/MarkMigratedTest.php +++ /dev/null @@ -1,475 +0,0 @@ -connection = ConnectionManager::get('test'); - $this->connection->getDriver()->connect(); - $this->pdo = $this->getDriverConnection($this->connection->getDriver()); - - $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); - $this->connection->execute('DROP TABLE IF EXISTS numbers'); - - $application = new MigrationsDispatcher('testing'); - $this->command = $application->find('mark_migrated'); - $this->commandTester = new CommandTester($this->command); - } - - /** - * tearDown method - * - * @return void - */ - public function tearDown(): void - { - parent::tearDown(); - $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); - $this->connection->execute('DROP TABLE IF EXISTS numbers'); - } - - /** - * Test executing "mark_migration" in a standard way - * - * @return void - */ - public function testExecute() - { - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Migration `20150826191400` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'Migration `20150724233100` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'Migration `20150704160200` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - - $result = $this->connection->selectQuery()->select(['*'])->from('phinxlog')->execute()->fetchAll('assoc'); - $this->assertEquals('20150704160200', $result[0]['version']); - $this->assertEquals('20150724233100', $result[1]['version']); - $this->assertEquals('20150826191400', $result[2]['version']); - - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Skipping migration `20150704160200` (already migrated).', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'Skipping migration `20150724233100` (already migrated).', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'Skipping migration `20150826191400` (already migrated).', - $this->commandTester->getDisplay(), - ); - - $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertEquals(4, $result->fetchColumn(0)); - - $config = $this->command->getConfig(); - $env = $this->command->getManager()->getEnvironment('default'); - $migrations = $this->command->getManager()->getMigrations('default'); - - $manager = $this->getMockBuilder(CakeManager::class) - ->onlyMethods(['getEnvironment', 'markMigrated', 'getMigrations']) - ->setConstructorArgs([$config, new ArgvInput([]), new StreamOutput(fopen('php://memory', 'a', false))]) - ->getMock(); - - $manager->expects($this->any()) - ->method('getEnvironment')->willReturn($env); - $manager->expects($this->any()) - ->method('getMigrations')->willReturn($migrations); - $manager - ->method('markMigrated')->will($this->throwException(new Exception('Error during marking process'))); - - $this->connection->execute('DELETE FROM phinxlog'); - - $application = new MigrationsDispatcher('testing'); - $buggyCommand = $application->find('mark_migrated'); - $buggyCommand->setManager($manager); - $buggyCommandTester = new TestCommandTester($buggyCommand); - $buggyCommandTester->execute([ - 'command' => $this->command->getName(), - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'An error occurred while marking migration `20150704160200` as migrated : Error during marking process', - $buggyCommandTester->getDisplay(), - ); - } - - /** - * Test executing "mark_migration" with deprecated `all` version - * - * @return void - */ - public function testExecuteAll() - { - $this->commandTester->execute([ - 'command' => $this->command->getName(), - 'version' => 'all', - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Migration `20150826191400` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'Migration `20150724233100` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'Migration `20150704160200` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'DEPRECATED: `all` or `*` as version is deprecated. Use `bin/cake migrations mark_migrated` instead', - $this->commandTester->getDisplay(), - ); - - $result = $this->connection->selectQuery()->select(['*'])->from('phinxlog')->execute()->fetchAll('assoc'); - $this->assertEquals('20150704160200', $result[0]['version']); - $this->assertEquals('20150724233100', $result[1]['version']); - $this->assertEquals('20150826191400', $result[2]['version']); - - $this->commandTester->execute([ - 'command' => $this->command->getName(), - 'version' => 'all', - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Skipping migration `20150704160200` (already migrated).', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'Skipping migration `20150724233100` (already migrated).', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'Skipping migration `20150826191400` (already migrated).', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'DEPRECATED: `all` or `*` as version is deprecated. Use `bin/cake migrations mark_migrated` instead', - $this->commandTester->getDisplay(), - ); - } - - public function testExecuteTarget() - { - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--target' => '20150704160200', - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Migration `20150704160200` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - - $result = $this->connection->selectQuery()->select(['*'])->from('phinxlog')->execute()->fetchAll('assoc'); - $this->assertEquals('20150704160200', $result[0]['version']); - - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--target' => '20150826191400', - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Skipping migration `20150704160200` (already migrated).', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'Migration `20150724233100` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'Migration `20150826191400` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - - $result = $this->connection->selectQuery()->select(['*'])->from('phinxlog')->execute()->fetchAll('assoc'); - $this->assertEquals('20150704160200', $result[0]['version']); - $this->assertEquals('20150724233100', $result[1]['version']); - $this->assertEquals('20150826191400', $result[2]['version']); - $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertEquals(3, $result->fetchColumn(0)); - - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--target' => '20150704160610', - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Migration `20150704160610` was not found !', - $this->commandTester->getDisplay(), - ); - } - - public function testExecuteTargetWithExclude() - { - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--target' => '20150724233100', - '--exclude' => true, - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Migration `20150704160200` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - - $result = $this->connection->selectQuery()->select(['*'])->from('phinxlog')->execute()->fetchAll('assoc'); - $this->assertEquals('20150704160200', $result[0]['version']); - - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--target' => '20150826191400', - '--exclude' => true, - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Skipping migration `20150704160200` (already migrated).', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'Migration `20150724233100` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - - $result = $this->connection->selectQuery()->select(['*'])->from('phinxlog')->execute()->fetchAll('assoc'); - $this->assertEquals('20150704160200', $result[0]['version']); - $this->assertEquals('20150724233100', $result[1]['version']); - $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertEquals(2, $result->fetchColumn(0)); - - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--target' => '20150704160610', - '--exclude' => true, - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Migration `20150704160610` was not found !', - $this->commandTester->getDisplay(), - ); - } - - public function testExecuteTargetWithOnly() - { - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--target' => '20150724233100', - '--only' => true, - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Migration `20150724233100` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - - $result = $this->connection->selectQuery()->select(['*'])->from('phinxlog')->execute()->fetchAll('assoc'); - $this->assertEquals('20150724233100', $result[0]['version']); - - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--target' => '20150826191400', - '--only' => true, - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Migration `20150826191400` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - - $result = $this->connection->selectQuery()->select(['*'])->from('phinxlog')->execute()->fetchAll('assoc'); - $this->assertEquals('20150826191400', $result[1]['version']); - $this->assertEquals('20150724233100', $result[0]['version']); - $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertEquals(2, $result->fetchColumn(0)); - - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--target' => '20150704160610', - '--only' => true, - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Migration `20150704160610` was not found !', - $this->commandTester->getDisplay(), - ); - } - - public function testExecuteWithVersionAsArgument() - { - $this->commandTester->execute([ - 'command' => $this->command->getName(), - 'version' => '20150724233100', - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $this->assertStringContainsString( - 'Migration `20150724233100` successfully marked migrated !', - $this->commandTester->getDisplay(), - ); - $this->assertStringContainsString( - 'DEPRECATED: VERSION as argument is deprecated. Use: ' . - '`bin/cake migrations mark_migrated --target=VERSION --only`', - $this->commandTester->getDisplay(), - ); - - $result = $this->connection->selectQuery()->select(['*'])->from('phinxlog')->execute()->fetchAll('assoc'); - $this->assertSame(1, count($result)); - $this->assertEquals('20150724233100', $result[0]['version']); - } - - public function testExecuteInvalidUseOfOnlyAndExclude() - { - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--exclude' => true, - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertEquals(0, $result->fetchColumn(0)); - $this->assertStringContainsString( - 'You should use `--exclude` OR `--only` (not both) along with a `--target` !', - $this->commandTester->getDisplay(), - ); - - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--only' => true, - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertEquals(0, $result->fetchColumn(0)); - $this->assertStringContainsString( - 'You should use `--exclude` OR `--only` (not both) along with a `--target` !', - $this->commandTester->getDisplay(), - ); - - $this->commandTester->execute([ - 'command' => $this->command->getName(), - '--target' => '20150724233100', - '--only' => true, - '--exclude' => true, - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]); - - $result = $this->connection->selectQuery()->select(['COUNT(*)'])->from('phinxlog')->execute(); - $this->assertEquals(0, $result->fetchColumn(0)); - $this->assertStringContainsString( - 'You should use `--exclude` OR `--only` (not both) along with a `--target` !', - $this->commandTester->getDisplay(), - ); - } -} diff --git a/tests/TestCase/Command/Phinx/SeedTest.php b/tests/TestCase/Command/Phinx/SeedTest.php deleted file mode 100644 index f30d3b837..000000000 --- a/tests/TestCase/Command/Phinx/SeedTest.php +++ /dev/null @@ -1,298 +0,0 @@ -connection = ConnectionManager::get('test'); - $this->connection->getDriver()->connect(); - $this->pdo = $this->getDriverConnection($this->connection->getDriver()); - - $application = new MigrationsDispatcher('testing'); - $this->command = $application->find('seed'); - $this->streamOutput = new StreamOutput(fopen('php://memory', 'w', false)); - } - - /** - * tearDown method - * - * @return void - */ - public function tearDown(): void - { - parent::tearDown(); - - $this->connection->execute('DROP TABLE IF EXISTS phinxlog'); - $this->connection->execute('DROP TABLE IF EXISTS numbers'); - $this->connection->execute('DROP TABLE IF EXISTS letters'); - $this->connection->execute('DROP TABLE IF EXISTS stores'); - } - - /** - * Test executing the "seed" command in a standard way - * - * @return void - */ - public function testExecute() - { - $params = [ - '--connection' => 'test', - ]; - $commandTester = $this->getCommandTester($params); - $migrations = $this->getMigrations(); - $migrations->migrate(); - - $commandTester->execute([ - 'command' => $this->command->getName(), - '--connection' => 'test', - '--seed' => 'NumbersSeed', - ]); - - $display = $this->getDisplayFromOutput(); - $this->assertTextContains('== NumbersSeed: seeded', $display); - - $result = $this->connection->selectQuery() - ->select(['*']) - ->from('numbers') - ->orderBy('id DESC') - ->limit(1) - ->execute()->fetchAll('assoc'); - $expected = [ - [ - 'id' => '1', - 'number' => '10', - 'radix' => '10', - ], - ]; - $this->assertEquals($expected, $result); - - $migrations->rollback(['target' => 'all']); - } - - /** - * Test executing the "seed" command with custom params - * - * @return void - */ - public function testExecuteCustomParams() - { - $params = [ - '--connection' => 'test', - '--source' => 'AltSeeds', - ]; - $commandTester = $this->getCommandTester($params); - $migrations = $this->getMigrations(); - $migrations->migrate(); - - $commandTester->execute([ - 'command' => $this->command->getName(), - '--connection' => 'test', - '--source' => 'AltSeeds', - ]); - - $display = $this->getDisplayFromOutput(); - $this->assertTextContains('== NumbersAltSeed: seeded', $display); - - $result = $this->connection->selectQuery() - ->select(['*']) - ->from('numbers') - ->orderBy('id DESC') - ->limit(1) - ->execute()->fetchAll('assoc'); - $expected = [ - [ - 'id' => '2', - 'number' => '5', - 'radix' => '10', - ], - ]; - $this->assertEquals($expected, $result); - $migrations->rollback(['target' => 'all']); - } - - /** - * Test executing the "seed" command with wrong custom params (no seed found) - * - * @return void - */ - public function testExecuteWrongCustomParams() - { - $params = [ - '--connection' => 'test', - '--source' => 'DerpSeeds', - ]; - $commandTester = $this->getCommandTester($params); - $migrations = $this->getMigrations(); - $migrations->migrate(); - - $commandTester->execute([ - 'command' => $this->command->getName(), - '--connection' => 'test', - '--source' => 'DerpSeeds', - ]); - - $display = $this->getDisplayFromOutput(); - $this->assertTextNotContains('seeded', $display); - $migrations->rollback(['target' => 'all']); - } - - /** - * Test executing the "seed" command with seeders using the call method - * - * @return void - */ - public function testExecuteSeedCallingOtherSeeders() - { - $params = [ - '--connection' => 'test', - '--source' => 'CallSeeds', - ]; - $commandTester = $this->getCommandTester($params); - $migrations = $this->getMigrations(); - $migrations->migrate(); - - $commandTester->execute([ - 'command' => $this->command->getName(), - '--connection' => 'test', - '--source' => 'CallSeeds', - '--seed' => 'DatabaseSeed', - ]); - - $display = $this->getDisplayFromOutput(); - $this->assertTextContains('==== NumbersCallSeed: seeded', $display); - $this->assertTextContains('==== LettersSeed: seeded', $display); - $migrations->rollback(['target' => 'all']); - } - - /** - * Gets a pre-configured of a CommandTester object that is initialized for each - * test methods. This is needed in order to define the same PDO connection resource - * between every objects needed during the tests. - * This is mandatory for the SQLite database vendor, so phinx objects interacting - * with the database have the same connection resource as CakePHP objects. - * - * @param array $params - * @return \Migrations\Test\CommandTester - */ - protected function getCommandTester($params) - { - $input = new ArrayInput($params, $this->command->getDefinition()); - $this->command->setInput($input); - $manager = new CakeManager($this->command->getConfig(), $input, $this->streamOutput); - $adapter = $manager - ->getEnvironment('default') - ->getAdapter(); - while ($adapter instanceof WrapperInterface) { - $adapter = $adapter->getAdapter(); - } - $adapter->setConnection($this->pdo); - $this->command->setManager($manager); - $commandTester = new CommandTester($this->command); - - return $commandTester; - } - - /** - * Gets a Migrations object in order to easily create and drop tables during the - * tests - * - * @return \Migrations\Migrations - */ - protected function getMigrations() - { - $params = [ - 'connection' => 'test', - 'source' => 'TestsMigrations', - ]; - $migrations = new Migrations($params); - $adapter = $migrations - ->getManager($this->command->getConfig()) - ->getEnvironment('default') - ->getAdapter(); - - while ($adapter instanceof WrapperInterface) { - $adapter = $adapter->getAdapter(); - } - - $adapter->setConnection($this->pdo); - - return $migrations; - } - - /** - * Extract the content that was stored in self::$output. - * - * @return string - */ - protected function getDisplayFromOutput() - { - rewind($this->streamOutput->getStream()); - $display = stream_get_contents($this->streamOutput->getStream()); - - return str_replace(PHP_EOL, "\n", $display); - } -} diff --git a/tests/TestCase/Command/Phinx/StatusTest.php b/tests/TestCase/Command/Phinx/StatusTest.php deleted file mode 100644 index 97f493d95..000000000 --- a/tests/TestCase/Command/Phinx/StatusTest.php +++ /dev/null @@ -1,285 +0,0 @@ -Connection = ConnectionManager::get('test'); - $this->Connection->getDriver()->connect(); - $this->pdo = $this->getDriverConnection($this->Connection->getDriver()); - - $this->Connection->execute('DROP TABLE IF EXISTS phinxlog'); - $this->Connection->execute('DROP TABLE IF EXISTS numbers'); - - $application = new MigrationsDispatcher('testing'); - $this->command = $application->find('status'); - $this->streamOutput = new StreamOutput(fopen('php://memory', 'w', false)); - } - - /** - * tearDown method - * - * @return void - */ - public function tearDown(): void - { - parent::tearDown(); - $this->Connection->execute('DROP TABLE IF EXISTS phinxlog'); - $this->Connection->execute('DROP TABLE IF EXISTS numbers'); - } - - /** - * Test executing the "status" command - * - * @return void - */ - public function testExecute() - { - $params = [ - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]; - $commandTester = $this->getCommandTester($params); - $commandTester->execute(['command' => $this->command->getName()] + $params); - - $display = $this->getDisplayFromOutput(); - $this->assertTextContains('down 20150704160200 CreateNumbersTable', $display); - $this->assertTextContains('down 20150724233100 UpdateNumbersTable', $display); - $this->assertTextContains('down 20150826191400 CreateLettersTable', $display); - } - - /** - * Test executing the "status" command with the JSON option - * - * @return void - */ - public function testExecuteJson() - { - $params = [ - '--connection' => 'test', - '--source' => 'TestsMigrations', - '--format' => 'json', - ]; - $commandTester = $this->getCommandTester($params); - $commandTester->execute(['command' => $this->command->getName()] + $params); - $display = $this->getDisplayFromOutput(); - - $expected = '[{"status":"down","id":20150704160200,"name":"CreateNumbersTable"},{"status":"down","id":20150724233100,"name":"UpdateNumbersTable"},{"status":"down","id":20150826191400,"name":"CreateLettersTable"},{"status":"down","id":20230628181900,"name":"CreateStoresTable"}]'; - - $this->assertTextContains($expected, $display); - } - - /** - * Test executing the "status" command with the migrated migrations - * - * @return void - */ - public function testExecuteWithMigrated() - { - $params = [ - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]; - $this->getCommandTester($params); - $migrations = $this->getMigrations(); - $migrations->migrate(); - - $params = [ - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]; - $commandTester = $this->getCommandTester($params); - $commandTester->execute(['command' => $this->command->getName()] + $params); - - $display = $this->getDisplayFromOutput(); - $this->assertTextContains('up 20150704160200 CreateNumbersTable', $display); - $this->assertTextContains('up 20150724233100 UpdateNumbersTable', $display); - $this->assertTextContains('up 20150826191400 CreateLettersTable', $display); - - $migrations->rollback(['target' => 'all']); - } - - /** - * Test executing the "status" command with inconsistency in the migrations files - * - * @return void - */ - public function testExecuteWithInconsistency() - { - $params = [ - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]; - $this->getCommandTester($params); - $migrations = $this->getMigrations(); - $migrations->migrate(); - - $migrations = $this->getMigrations(); - $migrationPaths = $migrations->getConfig()->getMigrationPaths(); - $migrationPath = array_pop($migrationPaths); - $origin = $migrationPath . DS . '20150724233100_update_numbers_table.php'; - $destination = $migrationPath . DS . '_20150724233100_update_numbers_table.php'; - rename($origin, $destination); - - $params = [ - '--connection' => 'test', - '--source' => 'TestsMigrations', - ]; - $commandTester = $this->getCommandTester($params); - $commandTester->execute(['command' => $this->command->getName()] + $params); - - $display = $this->getDisplayFromOutput(); - $this->assertTextContains('up 20150704160200 CreateNumbersTable', $display); - $this->assertTextContains('up 20150724233100 UpdateNumbersTable ** MISSING **', $display); - $this->assertTextContains('up 20150826191400 CreateLettersTable', $display); - - rename($destination, $origin); - - $migrations->rollback(['target' => 'all']); - } - - /** - * Gets a pre-configured of a CommandTester object that is initialized for each - * test methods. This is needed in order to define the same PDO connection resource - * between every objects needed during the tests. - * This is mandatory for the SQLite database vendor, so phinx objects interacting - * with the database have the same connection resource as CakePHP objects. - * - * @param array $params - * @return \Migrations\Test\CommandTester - */ - protected function getCommandTester($params) - { - $input = new ArrayInput($params, $this->command->getDefinition()); - $this->command->setInput($input); - $manager = new CakeManager($this->command->getConfig(), $input, $this->streamOutput); - $adapter = $manager - ->getEnvironment('default') - ->getAdapter(); - while ($adapter instanceof WrapperInterface) { - $adapter = $adapter->getAdapter(); - } - $adapter->setConnection($this->pdo); - $this->command->setManager($manager); - $commandTester = new CommandTester($this->command); - - return $commandTester; - } - - /** - * Gets a Migrations object in order to easily create and drop tables during the - * tests - * - * @return \Migrations\Migrations - */ - protected function getMigrations() - { - $params = [ - 'connection' => 'test', - 'source' => 'TestsMigrations', - ]; - $args = [ - '--connection' => $params['connection'], - '--source' => $params['source'], - ]; - $input = new ArrayInput($args, $this->command->getDefinition()); - $migrations = new Migrations($params); - $migrations->setInput($input); - $this->command->setInput($input); - - $adapter = $migrations - ->getManager($this->command->getConfig()) - ->getEnvironment('default') - ->getAdapter(); - while ($adapter instanceof WrapperInterface) { - $adapter = $adapter->getAdapter(); - } - $adapter->setConnection($this->pdo); - - return $migrations; - } - - /** - * Extract the content that was stored in self::$streamOutput. - * - * @return string - */ - protected function getDisplayFromOutput() - { - rewind($this->streamOutput->getStream()); - $display = stream_get_contents($this->streamOutput->getStream()); - - return str_replace(PHP_EOL, "\n", $display); - } -} diff --git a/tests/TestCase/Command/RollbackCommandTest.php b/tests/TestCase/Command/RollbackCommandTest.php index c233e6d2d..da94d200c 100644 --- a/tests/TestCase/Command/RollbackCommandTest.php +++ b/tests/TestCase/Command/RollbackCommandTest.php @@ -3,38 +3,23 @@ namespace Migrations\Test\TestCase\Command; -use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; -use Cake\Core\Configure; -use Cake\Database\Exception\DatabaseException; use Cake\Datasource\ConnectionManager; use Cake\Event\EventInterface; use Cake\Event\EventManager; -use Cake\TestSuite\TestCase; use InvalidArgumentException; +use Migrations\Test\TestCase\TestCase; use ReflectionProperty; class RollbackCommandTest extends TestCase { - use ConsoleIntegrationTestTrait; - protected array $createdFiles = []; public function setUp(): void { parent::setUp(); - Configure::write('Migrations.backend', 'builtin'); - - try { - $table = $this->fetchTable('Phinxlog'); - $table->deleteAll('1=1'); - } catch (DatabaseException $e) { - } - try { - $table = $this->fetchTable('MigratorPhinxlog'); - $table->deleteAll('1=1'); - } catch (DatabaseException $e) { - } + $this->clearMigrationRecords('test'); + $this->clearMigrationRecords('test', 'Migrator'); } public function tearDown(): void @@ -73,8 +58,7 @@ public function testSourceMissing(): void $this->assertOutputContains('No migrations to rollback'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(0, $table->find()->all()->toArray()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->assertFileDoesNotExist($dumpFile); @@ -117,8 +101,8 @@ public function testExecuteDryRun(): void $this->assertOutputContains('20240309223600 MarkMigratedTestSecond: reverting'); $this->assertOutputContains('All Done'); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(2, $table->find()->all()->toArray()); + $count = $this->getMigrationRecordCount('test'); + $this->assertEquals(2, $count); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->assertFileDoesNotExist($dumpFile); @@ -226,8 +210,7 @@ public function testPluginOption(): void $this->assertExitSuccess(); // migration state was recorded. - $phinxlog = $this->fetchTable('MigratorPhinxlog'); - $this->assertEquals(1, $phinxlog->find()->count(), 'migrate makes a row'); + $this->assertEquals(1, $this->getMigrationRecordCount('test', 'Migrator'), 'migrate makes a row'); // Table was created. $this->assertNotEmpty($this->fetchTable('Migrator')->getSchema()); @@ -238,7 +221,7 @@ public function testPluginOption(): void $this->assertOutputContains('Migrator: reverted'); // No more recorded migrations - $this->assertEquals(0, $phinxlog->find()->count()); + $this->assertEquals(0, $this->getMigrationRecordCount('test', 'Migrator')); } public function testLockOption(): void @@ -264,8 +247,7 @@ public function testFakeOption(): void $this->exec('migrations migrate -c test --no-lock'); $this->assertExitSuccess(); $this->resetOutput(); - $table = $this->fetchTable('Phinxlog'); - $this->assertCount(2, $table->find()->all()->toArray()); + $this->assertEquals(2, $this->getMigrationRecordCount('test')); $this->exec('migrations rollback -c test --no-lock --target MarkMigratedTestSecond --fake'); $this->assertExitSuccess(); @@ -273,7 +255,7 @@ public function testFakeOption(): void $this->assertOutputContains('performing fake rollbacks'); $this->assertOutputContains('MarkMigratedTestSecond: reverted'); - $this->assertCount(0, $table->find()->all()->toArray()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); $dumpFile = $migrationPath . DS . 'schema-dump-test.lock'; $this->assertFileDoesNotExist($dumpFile); @@ -312,7 +294,6 @@ public function testBeforeMigrateEventAbort(): void // Only one event was fired $this->assertSame(['Migration.beforeRollback'], $fired); - $table = $this->fetchTable('Phinxlog'); - $this->assertEquals(0, $table->find()->count()); + $this->assertEquals(0, $this->getMigrationRecordCount('test')); } } diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php index 2f219f92f..ac2401712 100644 --- a/tests/TestCase/Command/SeedCommandTest.php +++ b/tests/TestCase/Command/SeedCommandTest.php @@ -3,32 +3,19 @@ namespace Migrations\Test\TestCase\Command; -use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; -use Cake\Core\Configure; -use Cake\Database\Exception\DatabaseException; use Cake\Datasource\ConnectionManager; use Cake\Event\EventInterface; use Cake\Event\EventManager; -use Cake\TestSuite\TestCase; use InvalidArgumentException; -use Phinx\Config\FeatureFlags; -use ReflectionClass; -use ReflectionProperty; +use Migrations\Test\TestCase\TestCase; class SeedCommandTest extends TestCase { - use ConsoleIntegrationTestTrait; - public function setUp(): void { parent::setUp(); - Configure::write('Migrations.backend', 'builtin'); - $table = $this->fetchTable('Phinxlog'); - try { - $table->deleteAll('1=1'); - } catch (DatabaseException $e) { - } + $this->clearMigrationRecords('test'); } public function tearDown(): void @@ -40,36 +27,23 @@ public function tearDown(): void $connection->execute('DROP TABLE IF EXISTS numbers'); $connection->execute('DROP TABLE IF EXISTS letters'); $connection->execute('DROP TABLE IF EXISTS stores'); - - if (class_exists(FeatureFlags::class)) { - $reflection = new ReflectionClass(FeatureFlags::class); - if ($reflection->hasProperty('addTimestampsUseDateTime')) { - FeatureFlags::$addTimestampsUseDateTime = false; - } - } - } - - protected function resetOutput(): void - { - if ($this->_out) { - $property = new ReflectionProperty($this->_out, '_out'); - $property->setValue($this->_out, []); - } + $connection->execute('DROP TABLE IF EXISTS cake_seeds'); } protected function createTables(): void { $this->exec('migrations migrate -c test -s TestsMigrations --no-lock'); $this->assertExitSuccess(); - $this->resetOutput(); + $this->_in = null; } public function testHelp(): void { - $this->exec('migrations seed --help'); + $this->exec('seeds run --help'); $this->assertExitSuccess(); $this->assertOutputContains('Seed the database with data'); - $this->assertOutputContains('migrations seed --connection secondary --seed UserSeed'); + $this->assertOutputContains('seeds run Posts'); + $this->assertOutputContains('seeds run Users,Posts'); } public function testSeederEvents(): void @@ -84,7 +58,7 @@ public function testSeederEvents(): void }); $this->createTables(); - $this->exec('migrations seed -c test --seed NumbersSeed'); + $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); $this->assertSame(['Migration.beforeSeed', 'Migration.afterSeed'], $fired); @@ -103,7 +77,7 @@ public function testBeforeSeederAbort(): void }); $this->createTables(); - $this->exec('migrations seed -c test --seed NumbersSeed'); + $this->exec('seeds run -c test NumbersSeed'); $this->assertExitError(); $this->assertSame(['Migration.beforeSeed'], $fired); @@ -113,16 +87,16 @@ public function testSeederUnknown(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The seed `NotThere` does not exist'); - $this->exec('migrations seed -c test --seed NotThere'); + $this->exec('seeds run -c test NotThere'); } public function testSeederOne(): void { $this->createTables(); - $this->exec('migrations seed -c test --seed NumbersSeed'); + $this->exec('seeds run -c test NumbersSeed'); $this->assertExitSuccess(); - $this->assertOutputContains('NumbersSeed: seeding'); + $this->assertOutputContains('Numbers seed: seeding'); $this->assertOutputContains('All Done'); /** @var \Cake\Database\Connection $connection */ @@ -134,10 +108,10 @@ public function testSeederOne(): void public function testSeederBaseSeed(): void { $this->createTables(); - $this->exec('migrations seed -c test --source BaseSeeds --seed MigrationSeedNumbers'); + $this->exec('seeds run -c test --source BaseSeeds MigrationSeedNumbers'); $this->assertExitSuccess(); - $this->assertOutputContains('MigrationSeedNumbers: seeding'); - $this->assertOutputContains('AnotherNumbersSeed: seeding'); + $this->assertOutputContains('MigrationSeedNumbers seed: seeding'); + $this->assertOutputContains('AnotherNumbers seed: seeding'); $this->assertOutputContains('radix=10'); $this->assertOutputContains('fetchRow=121'); $this->assertOutputContains('hasTable=1'); @@ -153,11 +127,11 @@ public function testSeederBaseSeed(): void public function testSeederImplicitAll(): void { $this->createTables(); - $this->exec('migrations seed -c test'); + $this->exec('seeds run -c test -q'); $this->assertExitSuccess(); - $this->assertOutputContains('NumbersSeed: seeding'); - $this->assertOutputContains('All Done'); + $this->assertOutputNotContains('The following seeds will be executed:'); + $this->assertOutputNotContains('Do you want to continue?'); /** @var \Cake\Database\Connection $connection */ $connection = ConnectionManager::get('test'); @@ -171,17 +145,17 @@ public function testSeederMultipleNotFound(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The seed `NotThere` does not exist'); - $this->exec('migrations seed -c test --seed NumbersSeed --seed NotThere'); + $this->exec('seeds run -c test NumbersSeed,NotThere'); } public function testSeederMultiple(): void { $this->createTables(); - $this->exec('migrations seed -c test --source CallSeeds --seed LettersSeed --seed NumbersCallSeed'); + $this->exec('seeds run -c test --source CallSeeds LettersSeed,NumbersCallSeed'); $this->assertExitSuccess(); - $this->assertOutputContains('NumbersCallSeed: seeding'); - $this->assertOutputContains('LettersSeed: seeding'); + $this->assertOutputContains('NumbersCall seed: seeding'); + $this->assertOutputContains('Letters seed: seeding'); $this->assertOutputContains('All Done'); /** @var \Cake\Database\Connection $connection */ @@ -199,55 +173,16 @@ public function testSeederSourceNotFound(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The seed `LettersSeed` does not exist'); - $this->exec('migrations seed -c test --source NotThere --seed LettersSeed'); + $this->exec('seeds run -c test --source NotThere LettersSeed'); } public function testSeederWithTimestampFields(): void { - if (class_exists(FeatureFlags::class)) { - $reflection = new ReflectionClass(FeatureFlags::class); - if ($reflection->hasProperty('addTimestampsUseDateTime')) { - FeatureFlags::$addTimestampsUseDateTime = false; - } - } - - $this->createTables(); - $this->exec('migrations seed -c test --seed StoresSeed'); - - $this->assertExitSuccess(); - $this->assertOutputContains('StoresSeed: seeding'); - $this->assertOutputContains('All Done'); - - /** @var \Cake\Database\Connection $connection */ - $connection = ConnectionManager::get('test'); - $result = $connection->selectQuery() - ->select(['*']) - ->from('stores') - ->orderBy('id DESC') - ->limit(1) - ->execute()->fetchAll('assoc'); - - $this->assertNotEmpty($result[0]); - $store = $result[0]; - $this->assertEquals('foo_with_date', $store['name']); - $this->assertNotEmpty($store['created']); - $this->assertNotEmpty($store['modified']); - } - - public function testSeederWithDateTimeFields(): void - { - $this->skipIf(!class_exists(FeatureFlags::class)); - - $reflection = new ReflectionClass(FeatureFlags::class); - $this->skipIf(!$reflection->hasProperty('addTimestampsUseDateTime')); - - FeatureFlags::$addTimestampsUseDateTime = true; - $this->createTables(); - $this->exec('migrations seed -c test --seed StoresSeed'); + $this->exec('seeds run -c test StoresSeed'); $this->assertExitSuccess(); - $this->assertOutputContains('StoresSeed: seeding'); + $this->assertOutputContains('Stores seed: seeding'); $this->assertOutputContains('All Done'); /** @var \Cake\Database\Connection $connection */ @@ -263,28 +198,28 @@ public function testSeederWithDateTimeFields(): void $store = $result[0]; $this->assertEquals('foo_with_date', $store['name']); $this->assertNotEmpty($store['created']); - $this->assertNotEmpty($store['modified']); + $this->assertNotEmpty($store['updated']); } public function testDryRunModeWarning(): void { $this->createTables(); - $this->exec('migrations seed -c test --seed NumbersSeed --dry-run'); + $this->exec('seeds run -c test NumbersSeed --dry-run'); $this->assertExitSuccess(); $this->assertOutputContains('DRY-RUN mode enabled'); - $this->assertOutputContains('NumbersSeed: seeding'); + $this->assertOutputContains('Numbers seed: seeding'); $this->assertOutputContains('All Done'); } public function testDryRunModeShortOption(): void { $this->createTables(); - $this->exec('migrations seed -c test --seed NumbersSeed -x'); + $this->exec('seeds run -c test NumbersSeed -d'); $this->assertExitSuccess(); $this->assertOutputContains('DRY-RUN mode enabled'); - $this->assertOutputContains('NumbersSeed: seeding'); + $this->assertOutputContains('Numbers seed: seeding'); $this->assertOutputContains('All Done'); } @@ -296,7 +231,7 @@ public function testDryRunModeNoDataChanges(): void $connection = ConnectionManager::get('test'); $initialCount = $connection->execute('SELECT COUNT(*) FROM numbers')->fetchColumn(0); - $this->exec('migrations seed -c test --seed NumbersSeed --dry-run'); + $this->exec('seeds run -c test NumbersSeed --dry-run'); $this->assertExitSuccess(); $finalCount = $connection->execute('SELECT COUNT(*) FROM numbers')->fetchColumn(0); @@ -306,12 +241,12 @@ public function testDryRunModeNoDataChanges(): void public function testDryRunModeMultipleSeeds(): void { $this->createTables(); - $this->exec('migrations seed -c test --source CallSeeds --seed LettersSeed --seed NumbersCallSeed --dry-run'); + $this->exec('seeds run -c test --source CallSeeds LettersSeed,NumbersCallSeed --dry-run'); $this->assertExitSuccess(); $this->assertOutputContains('DRY-RUN mode enabled'); - $this->assertOutputContains('NumbersCallSeed: seeding'); - $this->assertOutputContains('LettersSeed: seeding'); + $this->assertOutputContains('NumbersCall seed: seeding'); + $this->assertOutputContains('Letters seed: seeding'); $this->assertOutputContains('All Done'); /** @var \Cake\Database\Connection $connection */ @@ -331,10 +266,8 @@ public function testDryRunModeAllSeeds(): void $connection = ConnectionManager::get('test'); $initialCount = $connection->execute('SELECT COUNT(*) FROM numbers')->fetchColumn(0); - $this->exec('migrations seed -c test --dry-run'); + $this->exec('seeds run -c test --dry-run -q'); $this->assertExitSuccess(); - $this->assertOutputContains('DRY-RUN mode enabled'); - $this->assertOutputContains('NumbersSeed: seeding'); $finalCount = $connection->execute('SELECT COUNT(*) FROM numbers')->fetchColumn(0); $this->assertEquals($initialCount, $finalCount, 'Dry-run mode should not modify database when running all seeds'); @@ -352,7 +285,7 @@ public function testDryRunModeWithEvents(): void }); $this->createTables(); - $this->exec('migrations seed -c test --seed NumbersSeed --dry-run'); + $this->exec('seeds run -c test NumbersSeed --dry-run'); $this->assertExitSuccess(); $this->assertOutputContains('DRY-RUN mode enabled'); @@ -367,12 +300,414 @@ public function testDryRunModeWithStoresSeed(): void $connection = ConnectionManager::get('test'); $initialCount = $connection->execute('SELECT COUNT(*) FROM stores')->fetchColumn(0); - $this->exec('migrations seed -c test --seed StoresSeed --dry-run'); + $this->exec('seeds run -c test StoresSeed --dry-run'); $this->assertExitSuccess(); $this->assertOutputContains('DRY-RUN mode enabled'); - $this->assertOutputContains('StoresSeed: seeding'); + $this->assertOutputContains('Stores seed: seeding'); $finalCount = $connection->execute('SELECT COUNT(*) FROM stores')->fetchColumn(0); $this->assertEquals($initialCount, $finalCount, 'Dry-run mode should not modify stores table'); } + + public function testSeederAnonymousClass(): void + { + $this->createTables(); + $this->exec('seeds run -c test AnonymousStoreSeed'); + + $this->assertExitSuccess(); + $this->assertOutputContains('AnonymousStore seed: seeding'); + $this->assertOutputContains('All Done'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM stores'); + $this->assertEquals(2, $query->fetchColumn(0)); + + $result = $connection->execute('SELECT * FROM stores ORDER BY id')->fetchAll('assoc'); + $this->assertEquals('anonymous_store', $result[0]['name']); + $this->assertEquals('other_store', $result[1]['name']); + } + + public function testSeederShortName(): void + { + $this->createTables(); + $this->exec('seeds run -c test Numbers'); + + $this->assertExitSuccess(); + $this->assertOutputContains('Numbers seed: seeding'); + $this->assertOutputContains('All Done'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + } + + public function testSeederShortNameMultiple(): void + { + $this->createTables(); + $this->exec('seeds run -c test --source CallSeeds Letters,NumbersCall'); + + $this->assertExitSuccess(); + $this->assertOutputContains('NumbersCall seed: seeding'); + $this->assertOutputContains('Letters seed: seeding'); + $this->assertOutputContains('All Done'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + + $query = $connection->execute('SELECT COUNT(*) FROM letters'); + $this->assertEquals(2, $query->fetchColumn(0)); + } + + public function testSeederShortNameAnonymous(): void + { + $this->createTables(); + $this->exec('seeds run -c test AnonymousStore'); + + $this->assertExitSuccess(); + $this->assertOutputContains('AnonymousStore seed: seeding'); + $this->assertOutputContains('All Done'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM stores'); + $this->assertEquals(2, $query->fetchColumn(0)); + } + + public function testSeederAllWithQuietModeSkipsConfirmation(): void + { + $this->createTables(); + // Quiet mode should skip confirmation prompt + $this->exec('seeds run -c test -q'); + + $this->assertExitSuccess(); + $this->assertOutputNotContains('The following seeds will be executed:'); + $this->assertOutputNotContains('Do you want to continue?'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + } + + public function testSeederAllHasConfirmation(): void + { + $this->createTables(); + // Confirm run all. + $this->exec('seeds run -c test', ['y']); + + $this->assertExitSuccess(); + $this->assertOutputContains('The following seeds will be executed:'); + $this->assertOutputContains('Do you want to continue?'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + } + + public function testSeederSpecificSeedSkipsConfirmation(): void + { + $this->createTables(); + $this->exec('seeds run -c test NumbersSeed'); + + $this->assertExitSuccess(); + $this->assertOutputNotContains('The following seeds will be executed:'); + $this->assertOutputNotContains('Do you want to continue?'); + $this->assertOutputContains('Numbers seed: seeding'); + $this->assertOutputContains('All Done'); + } + + public function testSeederCommaSeparated(): void + { + $this->createTables(); + $this->exec('seeds run -c test --source CallSeeds Letters,NumbersCall'); + + $this->assertExitSuccess(); + $this->assertOutputContains('NumbersCall seed: seeding'); + $this->assertOutputContains('Letters seed: seeding'); + $this->assertOutputContains('All Done'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + + $query = $connection->execute('SELECT COUNT(*) FROM letters'); + $this->assertEquals(2, $query->fetchColumn(0)); + } + + public function testSeedStateTracking(): void + { + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // First run should execute the seed + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('Numbers seed: seeding'); + $this->assertOutputContains('All Done'); + + // Verify data was inserted + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + + // Second run should skip the seed (already executed) + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('Numbers seed: already executed'); + $this->assertOutputNotContains('seeding'); + + // Verify no additional data was inserted + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + + // Run with --force should re-execute + $this->exec('seeds run -c test NumbersSeed --force'); + $this->assertExitSuccess(); + $this->assertOutputContains('Numbers seed: seeding'); + + // Verify data was inserted again (now 2 records) + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(2, $query->fetchColumn(0)); + } + + public function testSeedStatusCommand(): void + { + $this->createTables(); + + // Check status before running seeds + $this->exec('seeds status -c test'); + $this->assertExitSuccess(); + $this->assertOutputContains('Current seed execution status:'); + $this->assertOutputContains('pending'); + + // Run a seed + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + + // Check status after running seed + $this->exec('seeds status -c test'); + $this->assertExitSuccess(); + $this->assertOutputContains('executed'); + $this->assertOutputContains('Numbers'); + } + + public function testSeedResetCommand(): void + { + $this->createTables(); + + // Run a seed + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + + // Reset the seed + $this->exec('seeds reset -c test', ['y']); + $this->assertExitSuccess(); + $this->assertOutputContains('All seeds will be reset:'); + + // Verify seed can be run again without --force + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + $this->assertOutputNotContains('already executed'); + } + + public function testIdempotentSeed(): void + { + $this->createTables(); + + // First run - should insert data + $this->exec('seeds run -c test -s TestSeeds IdempotentTest'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $query = $connection->execute('SELECT COUNT(*) FROM numbers WHERE number = 99'); + $this->assertEquals(1, $query->fetchColumn(0)); + + // Second run - should run again (not skip) and insert another row + $this->exec('seeds run -c test -s TestSeeds IdempotentTest'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + $this->assertOutputNotContains('already executed'); + + // Verify it ran again and inserted another row + $query = $connection->execute('SELECT COUNT(*) FROM numbers WHERE number = 99'); + $this->assertEquals(2, $query->fetchColumn(0)); + + // Verify the seed was NOT tracked in cake_seeds table + $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'IdempotentTestSeed\''); + $this->assertEquals(0, $seedLog->fetchColumn(0), 'Idempotent seeds should not be tracked'); + } + + public function testNonIdempotentSeedIsTracked(): void + { + $this->createTables(); + + // Run a regular (non-idempotent) seed + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Verify the seed WAS tracked in cake_seeds table + $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\''); + $this->assertEquals(1, $seedLog->fetchColumn(0), 'Regular seeds should be tracked'); + + // Run again - should be skipped + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('already executed'); + $this->assertOutputNotContains('seeding'); + } + + public function testFakeSeedMarksAsExecuted(): void + { + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Run with --fake flag + $this->exec('seeds run -c test NumbersSeed --fake'); + $this->assertExitSuccess(); + $this->assertErrorContains('performing fake seeding'); + $this->assertOutputContains('faking'); + $this->assertOutputContains('faked'); + $this->assertOutputNotContains('seeding'); + + // Verify NO data was inserted + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(0, $query->fetchColumn(0), 'Fake seed should not insert data'); + + // Verify the seed WAS tracked in cake_seeds table + $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\''); + $this->assertEquals(1, $seedLog->fetchColumn(0), 'Fake seeds should be tracked'); + + // Running again should show already executed + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + $this->assertOutputContains('already executed'); + } + + public function testFakeSeedWithForce(): void + { + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Run with --fake first + $this->exec('seeds run -c test NumbersSeed --fake'); + $this->assertExitSuccess(); + + // Verify seed is tracked + $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\''); + $this->assertEquals(1, $seedLog->fetchColumn(0)); + + // Run with --force to actually execute it + $this->exec('seeds run -c test NumbersSeed --force'); + $this->assertExitSuccess(); + $this->assertOutputContains('seeding'); + + // Verify data was inserted + $query = $connection->execute('SELECT COUNT(*) FROM numbers'); + $this->assertEquals(1, $query->fetchColumn(0)); + } + + public function testResetSpecificSeed(): void + { + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Run two seeds + $this->exec('seeds run -c test NumbersSeed'); + $this->assertExitSuccess(); + + $this->exec('seeds run -c test StoresSeed'); + $this->assertExitSuccess(); + + // Verify both are tracked + $numbersLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\''); + $this->assertEquals(1, $numbersLog->fetchColumn(0)); + + $storesLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'StoresSeed\''); + $this->assertEquals(1, $storesLog->fetchColumn(0)); + + // Reset only Numbers seed + $this->exec('seeds reset -c test --seed Numbers', ['y']); + $this->assertExitSuccess(); + $this->assertOutputContains('The following seeds will be reset:'); + $this->assertOutputNotContains('All seeds will be reset:'); + + // Verify Numbers is reset but Stores is still tracked + $numbersLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\''); + $this->assertEquals(0, $numbersLog->fetchColumn(0), 'Numbers seed should be reset'); + + $storesLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'StoresSeed\''); + $this->assertEquals(1, $storesLog->fetchColumn(0), 'Stores seed should still be tracked'); + } + + public function testResetMultipleSpecificSeeds(): void + { + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Run seeds + $this->exec('seeds run -c test NumbersSeed'); + $this->exec('seeds run -c test StoresSeed'); + + // Reset both with comma-separated list + $this->exec('seeds reset -c test --seed Numbers,Stores', ['y']); + $this->assertExitSuccess(); + + // Verify both are reset + $numbersLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'NumbersSeed\''); + $this->assertEquals(0, $numbersLog->fetchColumn(0)); + + $storesLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'StoresSeed\''); + $this->assertEquals(0, $storesLog->fetchColumn(0)); + } + + public function testResetNonExistentSeed(): void + { + $this->createTables(); + + $this->exec('seeds reset -c test --seed NonExistent'); + $this->assertExitError(); + $this->assertErrorContains('Seed `NonExistent` does not exist'); + } + + public function testFakeIdempotentSeedIsSkipped(): void + { + $this->createTables(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // Run idempotent seed with --fake flag + $this->exec('seeds run -c test -s TestSeeds IdempotentTest --fake'); + $this->assertExitSuccess(); + $this->assertOutputContains('skipped (idempotent)'); + $this->assertOutputNotContains('faking'); + $this->assertOutputNotContains('faked'); + + // Verify the seed was NOT tracked (idempotent seeds are never tracked) + $seedLog = $connection->execute('SELECT COUNT(*) FROM cake_seeds WHERE seed_name = \'IdempotentTestSeed\''); + $this->assertEquals(0, $seedLog->fetchColumn(0), 'Idempotent seeds should not be tracked even when faked'); + } } diff --git a/tests/TestCase/Command/StatusCommandTest.php b/tests/TestCase/Command/StatusCommandTest.php index e1ea91023..48acaf229 100644 --- a/tests/TestCase/Command/StatusCommandTest.php +++ b/tests/TestCase/Command/StatusCommandTest.php @@ -3,27 +3,17 @@ namespace Migrations\Test\TestCase\Command; -use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; -use Cake\Core\Configure; use Cake\Core\Exception\MissingPluginException; -use Cake\Database\Exception\DatabaseException; -use Cake\TestSuite\TestCase; +use Migrations\Test\TestCase\TestCase; use RuntimeException; class StatusCommandTest extends TestCase { - use ConsoleIntegrationTestTrait; - public function setUp(): void { parent::setUp(); - Configure::write('Migrations.backend', 'builtin'); - $table = $this->fetchTable('Phinxlog'); - try { - $table->deleteAll('1=1'); - } catch (DatabaseException $e) { - } + $this->clearMigrationRecords('test'); } public function testHelp(): void @@ -38,6 +28,8 @@ public function testExecuteSimple(): void { $this->exec('migrations status -c test'); $this->assertExitSuccess(); + // Check for table name info + $this->assertOutputContains('using migration table'); // Check for headers $this->assertOutputContains('Status'); $this->assertOutputContains('Migration ID'); @@ -88,29 +80,21 @@ public function testCleanNoMissingMigrations(): void public function testCleanWithMissingMigrations(): void { - // First, insert a fake migration entry that doesn't exist in filesystem - $table = $this->fetchTable('Phinxlog'); - $entity = $table->newEntity([ - 'version' => 99999999999999, - 'migration_name' => 'FakeMissingMigration', - 'start_time' => '2024-01-01 00:00:00', - 'end_time' => '2024-01-01 00:00:01', - 'breakpoint' => false, - ]); - $table->save($entity); + // Run a migration first to ensure the schema table exists + $this->exec('migrations migrate -c test --no-lock'); + $this->assertExitSuccess(); + + // Insert a fake migration entry that doesn't exist in filesystem + $this->insertMigrationRecord('test', 99999999999999, 'FakeMissingMigration'); // Verify the fake migration is in the table - $count = $table->find()->where(['version' => 99999999999999])->count(); - $this->assertEquals(1, $count); + $initialCount = $this->getMigrationRecordCount('test'); + $this->assertGreaterThan(0, $initialCount); // Run the clean command $this->exec('migrations status -c test --cleanup'); $this->assertExitSuccess(); $this->assertOutputContains('Removed 1 missing migration(s) from migration log.'); - - // Verify the fake migration was removed - $count = $table->find()->where(['version' => 99999999999999])->count(); - $this->assertEquals(0, $count); } public function testCleanHelp(): void @@ -118,6 +102,6 @@ public function testCleanHelp(): void $this->exec('migrations status --help'); $this->assertExitSuccess(); $this->assertOutputContains('--cleanup'); - $this->assertOutputContains('Remove MISSING migrations from the phinxlog table'); + $this->assertOutputContains('Remove MISSING migrations from the'); } } diff --git a/tests/TestCase/Command/UpgradeCommandTest.php b/tests/TestCase/Command/UpgradeCommandTest.php new file mode 100644 index 000000000..0fe132717 --- /dev/null +++ b/tests/TestCase/Command/UpgradeCommandTest.php @@ -0,0 +1,121 @@ +clearMigrationRecords('test'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $connection->execute('DROP TABLE IF EXISTS cake_migrations'); + } + + protected function getAdapter(): AdapterInterface + { + $config = ConnectionManager::getConfig('test'); + $environment = new Environment('default', [ + 'connection' => 'test', + 'database' => $config['database'], + 'migration_table' => 'phinxlog', + ]); + + return $environment->getAdapter(); + } + + public function testHelp(): void + { + Configure::write('Migrations.legacyTables', null); + + $this->exec('migrations upgrade --help'); + $this->assertExitSuccess(); + $this->assertOutputContains('Upgrades migration tracking'); + $this->assertOutputContains('migrations upgrade --dry-run'); + } + + public function testExecuteSimpleDryRun(): void + { + Configure::write('Migrations.legacyTables', true); + try { + $this->getAdapter()->createSchemaTable(); + } catch (Exception $e) { + // Table probably exists + } + + $this->exec('migrations upgrade -c test --dry-run'); + $this->assertExitSuccess(); + // Check for status output + $this->assertOutputContains('DRY RUN'); + $this->assertOutputContains('Creating unified table'); + $this->assertOutputContains('Total records migrated'); + } + + public function testExecuteSimpleExecute(): void + { + Configure::write('Migrations.legacyTables', true); + $config = ConnectionManager::getConfig('test'); + $environment = new Environment('default', [ + 'connection' => 'test', + 'database' => $config['database'], + 'migration_table' => 'phinxlog', + ]); + $adapter = $environment->getAdapter(); + try { + $adapter->createSchemaTable(); + } catch (Exception $e) { + // Table probably exists + } + + $this->exec('migrations upgrade -c test'); + $this->assertExitSuccess(); + + // No dry run and drop table output is present. + $this->assertOutputNotContains('DRY RUN'); + $this->assertOutputContains('Creating unified table'); + $this->assertOutputContains('Total records migrated'); + + $this->assertTrue($adapter->hasTable('cake_migrations')); + $this->assertTrue($adapter->hasTable('phinxlog')); + } + + public function testExecuteSimpleExecuteDropTables(): void + { + Configure::write('Migrations.legacyTables', true); + $config = ConnectionManager::getConfig('test'); + $environment = new Environment('default', [ + 'connection' => 'test', + 'database' => $config['database'], + 'migration_table' => 'phinxlog', + ]); + $adapter = $environment->getAdapter(); + try { + $adapter->createSchemaTable(); + } catch (Exception $e) { + // Table probably exists + } + + $this->exec('migrations upgrade -c test --drop-tables'); + $this->assertExitSuccess(); + + // Check for status output + $this->assertOutputNotContains('DRY RUN'); + $this->assertOutputContains('Creating unified table'); + $this->assertOutputContains('Dropping legacy table'); + $this->assertOutputContains('Total records migrated'); + + $this->assertTrue($adapter->hasTable('cake_migrations')); + $this->assertFalse($adapter->hasTable('phinxlog')); + } +} diff --git a/tests/TestCase/Config/AbstractConfigTestCase.php b/tests/TestCase/Config/AbstractConfigTestCase.php index de9ec7b41..41656181e 100644 --- a/tests/TestCase/Config/AbstractConfigTestCase.php +++ b/tests/TestCase/Config/AbstractConfigTestCase.php @@ -39,20 +39,10 @@ public function getConfigArray() ]; return [ - 'default' => [ - 'paths' => [ - 'migrations' => '%%PHINX_CONFIG_PATH%%/testmigrations2', - 'seeds' => '%%PHINX_CONFIG_PATH%%/db/seeds', - ], - ], 'paths' => [ 'migrations' => $this->getMigrationPath(), 'seeds' => $this->getSeedPath(), ], - 'templates' => [ - 'file' => '%%PHINX_CONFIG_PATH%%/tpl/testtemplate.txt', - 'class' => '%%PHINX_CONFIG_PATH%%/tpl/testtemplate.php', - ], 'environment' => $adapter, ]; } diff --git a/tests/TestCase/Config/ConfigSeedTemplatePathsTest.php b/tests/TestCase/Config/ConfigSeedTemplatePathsTest.php deleted file mode 100644 index 580c7c61d..000000000 --- a/tests/TestCase/Config/ConfigSeedTemplatePathsTest.php +++ /dev/null @@ -1,61 +0,0 @@ - [ - 'seeds' => '/test', - ], - 'templates' => [ - 'seedFile' => 'seedFilePath', - ], - ]; - - $config = new Config($values); - - $actualValue = $config->getSeedTemplateFile(); - $this->assertEquals('seedFilePath', $actualValue); - } - - public function testTemplateIsSetButNoPath() - { - // Here is used another key just to keep the node 'template' not empty - $values = [ - 'paths' => [ - 'seeds' => '/test', - ], - 'templates' => [ - 'file' => 'migration_template_file', - ], - ]; - - $config = new Config($values); - - $actualValue = $config->getSeedTemplateFile(); - $this->assertNull($actualValue); - } - - public function testNoCustomSeedTemplate() - { - $values = [ - 'paths' => [ - 'seeds' => '/test', - ], - ]; - $config = new Config($values); - - $actualValue = $config->getSeedTemplateFile(); - $this->assertNull($actualValue); - - $config->getSeedPath(); - } -} diff --git a/tests/TestCase/Config/ConfigTest.php b/tests/TestCase/Config/ConfigTest.php index 21315e1c8..9197956be 100644 --- a/tests/TestCase/Config/ConfigTest.php +++ b/tests/TestCase/Config/ConfigTest.php @@ -48,37 +48,6 @@ public function testUndefinedArrayAccess() $config['foo']; } - public function testGetMigrationBaseClassNameGetsDefaultBaseClass() - { - $config = new Config([]); - $this->assertEquals('AbstractMigration', $config->getMigrationBaseClassName()); - } - - public function testGetMigrationBaseClassNameGetsDefaultBaseClassWithNamespace() - { - $config = new Config([]); - $this->assertEquals('Phinx\Migration\AbstractMigration', $config->getMigrationBaseClassName(false)); - } - - public function testGetMigrationBaseClassNameGetsAlternativeBaseClass() - { - $config = new Config(['migration_base_class' => 'Phinx\Migration\AlternativeAbstractMigration']); - $this->assertEquals('AlternativeAbstractMigration', $config->getMigrationBaseClassName()); - } - - public function testGetMigrationBaseClassNameGetsAlternativeBaseClassWithNamespace() - { - $config = new Config(['migration_base_class' => 'Phinx\Migration\AlternativeAbstractMigration']); - $this->assertEquals('Phinx\Migration\AlternativeAbstractMigration', $config->getMigrationBaseClassName(false)); - } - - public function testGetTemplateValuesFalseOnEmpty() - { - $config = new Config([]); - $this->assertFalse($config->getTemplateFile()); - $this->assertFalse($config->getTemplateClass()); - } - public function testGetSeedPath() { $config = new Config(['paths' => ['seeds' => 'db/seeds']]); @@ -98,26 +67,6 @@ public function testGetSeedPathThrowsException() $config->getSeedPath(); } - /** - * Checks if base class is returned correctly when specified without - * a namespace. - */ - public function testGetMigrationBaseClassNameNoNamespace() - { - $config = new Config(['migration_base_class' => 'BaseMigration']); - $this->assertEquals('BaseMigration', $config->getMigrationBaseClassName()); - } - - /** - * Checks if base class is returned correctly when specified without - * a namespace. - */ - public function testGetMigrationBaseClassNameNoNamespaceNoDrop() - { - $config = new Config(['migration_base_class' => 'BaseMigration']); - $this->assertEquals('BaseMigration', $config->getMigrationBaseClassName(false)); - } - public function testGetVersionOrder() { $config = new Config([]); @@ -155,28 +104,6 @@ public static function isVersionOrderCreationTimeDataProvider() ]; } - public function testDefaultTemplateStyle(): void - { - $config = new Config([]); - $this->assertSame('change', $config->getTemplateStyle()); - } - - public static function templateStyleDataProvider(): array - { - return [ - ['change', 'change'], - ['up_down', 'up_down'], - ['foo', 'change'], - ]; - } - - #[DataProvider('templateStyleDataProvider')] - public function testTemplateStyle(string $style, string $expected): void - { - $config = new Config(['templates' => ['style' => $style]]); - $this->assertSame($expected, $config->getTemplateStyle()); - } - public function testIsDryRunDefaultFalse(): void { $config = new Config([]); diff --git a/tests/TestCase/ConfigurationTraitTest.php b/tests/TestCase/ConfigurationTraitTest.php deleted file mode 100644 index 9b6e9aaeb..000000000 --- a/tests/TestCase/ConfigurationTraitTest.php +++ /dev/null @@ -1,379 +0,0 @@ -command = new ExampleCommand(); - } - - public function tearDown(): void - { - parent::tearDown(); - ConnectionManager::drop('custom'); - ConnectionManager::drop('default'); - } - - /** - * Tests that the correct driver name is inferred from the driver - * instance that is passed to getAdapterName() - * - * @return void - */ - public function testGetAdapterName() - { - $this->assertEquals('mysql', $this->command->getAdapterName('\Cake\Database\Driver\Mysql')); - $this->assertEquals('pgsql', $this->command->getAdapterName('\Cake\Database\Driver\Postgres')); - $this->assertEquals('sqlite', $this->command->getAdapterName('\Cake\Database\Driver\Sqlite')); - } - - /** - * Tests that the configuration object is created out of the database configuration - * made for the application - * - * @return void - */ - public function testGetConfig() - { - if (!extension_loaded('pdo_mysql')) { - $this->markTestSkipped('Cannot run without pdo_mysql'); - } - ConnectionManager::setConfig('default', [ - 'className' => 'Cake\Database\Connection', - 'driver' => 'Cake\Database\Driver\Mysql', - 'host' => 'foo.bar', - 'username' => 'root', - 'password' => 'the_password', - 'database' => 'the_database', - 'encoding' => 'utf-8', - 'ssl_ca' => '/certs/my_cert', - 'ssl_key' => 'ssl_key_value', - 'ssl_cert' => 'ssl_cert_value', - 'flags' => [ - PDO::ATTR_EMULATE_PREPARES => true, - PDO::MYSQL_ATTR_SSL_CA => 'flags do not overwrite config', - PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => false, - ], - ]); - - /** @var \Symfony\Component\Console\Input\InputInterface|\PHPUnit\Framework\MockObject\MockObject $input */ - $input = $this->getMockBuilder(InputInterface::class)->getMock(); - $this->command->setInput($input); - $config = $this->command->getConfig(); - $this->assertInstanceOf('Phinx\Config\Config', $config); - - $expected = ROOT . DS . 'config' . DS . 'Migrations'; - $migrationPaths = $config->getMigrationPaths(); - $this->assertSame($expected, array_pop($migrationPaths)); - - $this->assertSame( - 'phinxlog', - $config['environments']['default_migration_table'], - ); - - $environment = $config['environments']['default']; - $this->assertSame('mysql', $environment['adapter']); - $this->assertSame('foo.bar', $environment['host']); - - $this->assertSame('root', $environment['user']); - $this->assertSame('the_password', $environment['pass']); - $this->assertSame('the_database', $environment['name']); - $this->assertSame('utf-8', $environment['charset']); - $this->assertSame('/certs/my_cert', $environment['mysql_attr_ssl_ca']); - $this->assertSame('ssl_key_value', $environment['mysql_attr_ssl_key']); - $this->assertSame('ssl_cert_value', $environment['mysql_attr_ssl_cert']); - $this->assertFalse($environment['mysql_attr_ssl_verify_server_cert']); - $this->assertTrue($environment['attr_emulate_prepares']); - $this->assertSame([], $environment['dsn_options']); - } - - public function testGetConfigWithDsnOptions() - { - ConnectionManager::setConfig('default', [ - 'className' => 'Cake\Database\Connection', - 'driver' => 'Cake\Database\Driver\Sqlserver', - 'database' => 'the_database', - // DSN options - 'connectionPooling' => true, - 'failoverPartner' => 'Partner', - 'loginTimeout' => 123, - 'multiSubnetFailover' => true, - 'encrypt' => true, - 'trustServerCertificate' => true, - ]); - - /** @var \Symfony\Component\Console\Input\InputInterface|\PHPUnit\Framework\MockObject\MockObject $input */ - $input = $this->getMockBuilder(InputInterface::class)->getMock(); - $this->command->setInput($input); - $config = $this->command->getConfig(); - $this->assertInstanceOf('Phinx\Config\Config', $config); - - $environment = $config['environments']['default']; - $this->assertSame('sqlsrv', $environment['adapter']); - $this->assertSame( - [ - 'ConnectionPooling' => true, - 'Failover_Partner' => 'Partner', - 'LoginTimeout' => 123, - 'MultiSubnetFailover' => true, - 'Encrypt' => true, - 'TrustServerCertificate' => true, - ], - $environment['dsn_options'], - ); - } - - /** - * Tests that the when the Adapter is built, the Connection cache metadata - * feature is turned off to prevent "unknown column" errors when adding a column - * then adding data to that column - * - * @return void - */ - public function testCacheMetadataDisabled() - { - $input = new ArrayInput([], $this->command->getDefinition()); - /** @var \Symfony\Component\Console\Output\OutputInterface|\PHPUnit\Framework\MockObject\MockObject $output */ - $output = $this->getMockBuilder(OutputInterface::class)->getMock(); - $this->command->setInput($input); - - $input->setOption('connection', 'test'); - $this->command->bootstrap($input, $output); - $config = ConnectionManager::get('test')->config(); - $this->assertFalse($config['cacheMetadata']); - } - - /** - * Tests that another phinxlog table is used when passing the plugin option in the input - * - * @return void - */ - public function testGetConfigWithPlugin() - { - ConnectionManager::setConfig('default', [ - 'className' => 'Cake\Database\Connection', - 'driver' => 'Cake\Database\Driver\Mysql', - 'database' => 'the_database', - ]); - - $tmpPath = rtrim(sys_get_temp_dir(), DS) . DS; - Plugin::getCollection()->add(new BasePlugin([ - 'name' => 'MyPlugin', - 'path' => $tmpPath, - ])); - $input = new ArrayInput([], $this->command->getDefinition()); - $this->command->setInput($input); - - $input->setOption('plugin', 'MyPlugin'); - - $config = $this->command->getConfig(); - $this->assertInstanceOf('Phinx\Config\Config', $config); - - $this->assertSame( - 'my_plugin_phinxlog', - $config['environments']['default_migration_table'], - ); - } - - /** - * Tests that passing a connection option in the input will configure the environment - * to use that connection - * - * @return void - */ - public function testGetConfigWithConnectionName() - { - ConnectionManager::setConfig('custom', [ - 'className' => 'Cake\Database\Connection', - 'driver' => 'Cake\Database\Driver\Mysql', - 'host' => 'foo.bar.baz', - 'username' => 'rooty', - 'password' => 'the_password2', - 'database' => 'the_database2', - 'encoding' => 'utf-8', - ]); - - $input = new ArrayInput([], $this->command->getDefinition()); - $this->command->setInput($input); - - $input->setOption('connection', 'custom'); - - $config = $this->command->getConfig(); - $this->assertInstanceOf('Phinx\Config\Config', $config); - - $expected = ROOT . DS . 'config' . DS . 'Migrations'; - $migrationPaths = $config->getMigrationPaths(); - $this->assertSame($expected, array_pop($migrationPaths)); - - $this->assertSame( - 'phinxlog', - $config['environments']['default_migration_table'], - ); - - $environment = $config['environments']['default']; - $this->assertSame('mysql', $environment['adapter']); - $this->assertSame('foo.bar.baz', $environment['host']); - - $this->assertSame('rooty', $environment['user']); - $this->assertSame('the_password2', $environment['pass']); - $this->assertSame('the_database2', $environment['name']); - $this->assertSame('utf-8', $environment['charset']); - } - - /** - * Generates Command mock to override getOperationsPath return value - * - * @param string $migrationsPath - * @param string $seedsPath - * @return ExampleCommand - */ - protected function _getCommandMock(string $migrationsPath, string $seedsPath): ExampleCommand - { - $command = $this - ->getMockBuilder(ExampleCommand::class) - ->onlyMethods(['getOperationsPath']) - ->getMock(); - /** @var \Symfony\Component\Console\Input\InputInterface|\PHPUnit\Framework\MockObject\MockObject $input */ - $input = $this->getMockBuilder(InputInterface::class)->getMock(); - $command->setInput($input); - $command->expects($this->any()) - ->method('getOperationsPath') - ->willReturnMap([ - [$input, 'Migrations', $migrationsPath], - [$input, 'Seeds', $seedsPath], - ]); - - return $command; - } - - /** - * Test getConfig, migrations path does not exist, debug is disabled - * - * @return void - */ - public function testGetConfigNoMigrationsFolderDebugDisabled() - { - ConnectionManager::setConfig('default', [ - 'className' => 'Cake\Database\Connection', - 'driver' => 'Cake\Database\Driver\Mysql', - 'host' => 'foo.bar', - 'username' => 'root', - 'password' => 'the_password', - 'database' => 'the_database', - 'encoding' => 'utf-8', - ]); - Configure::write('debug', false); - $migrationsPath = ROOT . DS . 'config' . DS . 'TestGetConfigMigrations'; - $seedsPath = ROOT . DS . 'config' . DS . 'TestGetConfigSeeds'; - - $command = $this->_getCommandMock($migrationsPath, $seedsPath); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(sprintf( - 'Migrations path `%s` does not exist and cannot be created because `debug` is disabled.', - $migrationsPath, - )); - $command->getConfig(); - } - - /** - * Test getConfig, migrations path does exist but seeds path does not, debug is disabled - * - * @return void - */ - public function testGetConfigNoSeedsFolderDebugDisabled() - { - ConnectionManager::setConfig('default', [ - 'className' => 'Cake\Database\Connection', - 'driver' => 'Cake\Database\Driver\Mysql', - 'host' => 'foo.bar', - 'username' => 'root', - 'password' => 'the_password', - 'database' => 'the_database', - 'encoding' => 'utf-8', - ]); - Configure::write('debug', false); - - $migrationsPath = ROOT . DS . 'config' . DS . 'TestGetConfigMigrations'; - mkdir($migrationsPath, 0777, true); - $seedsPath = ROOT . DS . 'config' . DS . 'TestGetConfigSeeds'; - - $command = $this->_getCommandMock($migrationsPath, $seedsPath); - $this->assertFalse(is_dir($seedsPath)); - try { - $command->getConfig(); - } finally { - rmdir($migrationsPath); - } - } - - /** - * Test getConfig, migrations and seeds paths do not exist, debug is enabled - * - * @return void - */ - public function testGetConfigNoMigrationsOrSeedsFolderDebugEnabled() - { - ConnectionManager::setConfig('default', [ - 'className' => 'Cake\Database\Connection', - 'driver' => 'Cake\Database\Driver\Mysql', - 'host' => 'foo.bar', - 'username' => 'root', - 'password' => 'the_password', - 'database' => 'the_database', - 'encoding' => 'utf-8', - ]); - $migrationsPath = ROOT . DS . 'config' . DS . 'TestGetConfigMigrations'; - $seedsPath = ROOT . DS . 'config' . DS . 'TestGetConfigSeeds'; - mkdir($migrationsPath, 0777, true); - mkdir($seedsPath, 0777, true); - - $command = $this->_getCommandMock($migrationsPath, $seedsPath); - - $command->getConfig(); - - $this->assertTrue(is_dir($migrationsPath)); - $this->assertTrue(is_dir($seedsPath)); - - rmdir($migrationsPath); - rmdir($seedsPath); - } -} diff --git a/tests/TestCase/Db/Adapter/AbstractAdapterTest.php b/tests/TestCase/Db/Adapter/AbstractAdapterTest.php index cfe6b3004..425d5281d 100644 --- a/tests/TestCase/Db/Adapter/AbstractAdapterTest.php +++ b/tests/TestCase/Db/Adapter/AbstractAdapterTest.php @@ -3,11 +3,15 @@ namespace Migrations\Test\Db\Adapter; +use Cake\Core\Configure; +use Cake\Database\Connection; +use Cake\Datasource\ConnectionManager; +use Migrations\Config\Config; use Migrations\Db\Adapter\AbstractAdapter; +use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; use Migrations\Db\Literal; use Migrations\Test\TestCase\Db\Adapter\DefaultAdapterTrait; use PDOException; -use Phinx\Config\Config; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -15,7 +19,7 @@ class AbstractAdapterTest extends TestCase { /** - * @var \Phinx\Db\Adapter\AbstractAdapter|\PHPUnit\Framework\MockObject\MockObject + * @var \Migrations\Db\Adapter\AbstractAdapter|\PHPUnit\Framework\MockObject\MockObject */ private $adapter; @@ -40,74 +44,31 @@ public function testOptions() public function testOptionsSetSchemaTableName() { - $this->assertEquals('phinxlog', $this->adapter->getSchemaTableName()); + // When unified table mode is enabled, getSchemaTableName() returns cake_migrations + $expectedDefault = Configure::read('Migrations.legacyTables') === false + ? UnifiedMigrationsTableStorage::TABLE_NAME + : 'phinxlog'; + $this->assertEquals($expectedDefault, $this->adapter->getSchemaTableName()); $this->adapter->setOptions(['migration_table' => 'schema_table_test']); - $this->assertEquals('schema_table_test', $this->adapter->getSchemaTableName()); + // After explicitly setting migration_table, it should use that value in legacy mode + // But unified mode always returns cake_migrations + $expectedAfterSet = Configure::read('Migrations.legacyTables') === false + ? UnifiedMigrationsTableStorage::TABLE_NAME + : 'schema_table_test'; + $this->assertEquals($expectedAfterSet, $this->adapter->getSchemaTableName()); } public function testSchemaTableName() { - $this->assertEquals('phinxlog', $this->adapter->getSchemaTableName()); + $expectedDefault = Configure::read('Migrations.legacyTables') === false + ? UnifiedMigrationsTableStorage::TABLE_NAME + : 'phinxlog'; + $this->assertEquals($expectedDefault, $this->adapter->getSchemaTableName()); $this->adapter->setSchemaTableName('schema_table_test'); - $this->assertEquals('schema_table_test', $this->adapter->getSchemaTableName()); - } - - #[DataProvider('getVersionLogDataProvider')] - public function testGetVersionLog($versionOrder, $expectedOrderBy) - { - $adapter = new class (['version_order' => $versionOrder]) extends AbstractAdapter { - use DefaultAdapterTrait; - - public function getSchemaTableName(): string - { - return 'log'; - } - - public function quoteTableName(string $tableName): string - { - return "'$tableName'"; - } - - public function fetchAll(string $sql): array - { - return [ - [ - 'version' => '20120508120534', - 'key' => 'value', - ], - [ - 'version' => '20130508120534', - 'key' => 'value', - ], - ]; - } - }; - - // we expect the mock rows but indexed by version creation time - $expected = [ - '20120508120534' => [ - 'version' => '20120508120534', - 'key' => 'value', - ], - '20130508120534' => [ - 'version' => '20130508120534', - 'key' => 'value', - ], - ]; - - $this->assertEquals($expected, $adapter->getVersionLog()); - } - - public static function getVersionLogDataProvider() - { - return [ - 'With Creation Time Version Order' => [ - Config::VERSION_ORDER_CREATION_TIME, 'version ASC', - ], - 'With Execution Time Version Order' => [ - Config::VERSION_ORDER_EXECUTION_TIME, 'start_time ASC, version ASC', - ], - ]; + $expectedAfterSet = Configure::read('Migrations.legacyTables') === false + ? UnifiedMigrationsTableStorage::TABLE_NAME + : 'schema_table_test'; + $this->assertEquals($expectedAfterSet, $this->adapter->getSchemaTableName()); } public function testGetVersionLogInvalidVersionOrderKO() @@ -127,6 +88,11 @@ public function testGetVersionLongDryRun() $adapter = new class (['version_order' => Config::VERSION_ORDER_CREATION_TIME]) extends AbstractAdapter { use DefaultAdapterTrait; + public function getConnection(): Connection + { + return ConnectionManager::get('test'); + } + public function isDryRunEnabled(): bool { return true; @@ -188,80 +154,111 @@ public static function currentTimestampDefaultValueProvider(): array // CURRENT_TIMESTAMP on datetime types should NOT be quoted 'CURRENT_TIMESTAMP on datetime' => [ 'CURRENT_TIMESTAMP', - AbstractAdapter::PHINX_TYPE_DATETIME, + AbstractAdapter::TYPE_DATETIME, ' DEFAULT CURRENT_TIMESTAMP', ], 'CURRENT_TIMESTAMP on timestamp' => [ 'CURRENT_TIMESTAMP', - AbstractAdapter::PHINX_TYPE_TIMESTAMP, + AbstractAdapter::TYPE_TIMESTAMP, ' DEFAULT CURRENT_TIMESTAMP', ], 'CURRENT_TIMESTAMP on time' => [ 'CURRENT_TIMESTAMP', - AbstractAdapter::PHINX_TYPE_TIME, + AbstractAdapter::TYPE_TIME, ' DEFAULT CURRENT_TIMESTAMP', ], 'CURRENT_TIMESTAMP on date' => [ 'CURRENT_TIMESTAMP', - AbstractAdapter::PHINX_TYPE_DATE, + AbstractAdapter::TYPE_DATE, ' DEFAULT CURRENT_TIMESTAMP', ], 'CURRENT_TIMESTAMP(3) on datetime' => [ 'CURRENT_TIMESTAMP(3)', - AbstractAdapter::PHINX_TYPE_DATETIME, + AbstractAdapter::TYPE_DATETIME, ' DEFAULT CURRENT_TIMESTAMP(3)', ], + // CURRENT_DATE on date type should NOT be quoted + 'CURRENT_DATE on date' => [ + 'CURRENT_DATE', + AbstractAdapter::TYPE_DATE, + ' DEFAULT CURRENT_DATE', + ], + // CURRENT_DATE on non-date types SHOULD be quoted + 'CURRENT_DATE on datetime should be quoted' => [ + 'CURRENT_DATE', + AbstractAdapter::TYPE_DATETIME, + " DEFAULT 'CURRENT_DATE'", + ], + 'CURRENT_DATE on string should be quoted' => [ + 'CURRENT_DATE', + AbstractAdapter::TYPE_STRING, + " DEFAULT 'CURRENT_DATE'", + ], + + // CURRENT_TIME on time type should NOT be quoted + 'CURRENT_TIME on time' => [ + 'CURRENT_TIME', + AbstractAdapter::TYPE_TIME, + ' DEFAULT CURRENT_TIME', + ], + // CURRENT_TIME on non-time types SHOULD be quoted + 'CURRENT_TIME on datetime should be quoted' => [ + 'CURRENT_TIME', + AbstractAdapter::TYPE_DATETIME, + " DEFAULT 'CURRENT_TIME'", + ], + // CURRENT_TIMESTAMP on non-datetime types SHOULD be quoted (bug #1891) 'CURRENT_TIMESTAMP on string should be quoted' => [ 'CURRENT_TIMESTAMP', - AbstractAdapter::PHINX_TYPE_STRING, + AbstractAdapter::TYPE_STRING, " DEFAULT 'CURRENT_TIMESTAMP'", ], 'CURRENT_TIMESTAMP on text should be quoted' => [ 'CURRENT_TIMESTAMP', - AbstractAdapter::PHINX_TYPE_TEXT, + AbstractAdapter::TYPE_TEXT, " DEFAULT 'CURRENT_TIMESTAMP'", ], 'CURRENT_TIMESTAMP on char should be quoted' => [ 'CURRENT_TIMESTAMP', - AbstractAdapter::PHINX_TYPE_CHAR, + AbstractAdapter::TYPE_CHAR, " DEFAULT 'CURRENT_TIMESTAMP'", ], 'CURRENT_TIMESTAMP on integer should be quoted' => [ 'CURRENT_TIMESTAMP', - AbstractAdapter::PHINX_TYPE_INTEGER, + AbstractAdapter::TYPE_INTEGER, " DEFAULT 'CURRENT_TIMESTAMP'", ], // Regular string defaults should always be quoted 'Regular string default' => [ 'default_value', - AbstractAdapter::PHINX_TYPE_STRING, + AbstractAdapter::TYPE_STRING, " DEFAULT 'default_value'", ], 'Regular string on datetime' => [ 'some_string', - AbstractAdapter::PHINX_TYPE_DATETIME, + AbstractAdapter::TYPE_DATETIME, " DEFAULT 'some_string'", ], // Literal values should not be quoted 'Literal value' => [ Literal::from('NOW()'), - AbstractAdapter::PHINX_TYPE_DATETIME, + AbstractAdapter::TYPE_DATETIME, ' DEFAULT NOW()', ], // Boolean defaults 'Boolean true' => [ true, - AbstractAdapter::PHINX_TYPE_BOOLEAN, + AbstractAdapter::TYPE_BOOLEAN, ' DEFAULT 1', ], 'Boolean false' => [ false, - AbstractAdapter::PHINX_TYPE_BOOLEAN, + AbstractAdapter::TYPE_BOOLEAN, ' DEFAULT 0', ], diff --git a/tests/TestCase/Db/Adapter/DefaultAdapterTrait.php b/tests/TestCase/Db/Adapter/DefaultAdapterTrait.php index e62826a9e..61543f28b 100644 --- a/tests/TestCase/Db/Adapter/DefaultAdapterTrait.php +++ b/tests/TestCase/Db/Adapter/DefaultAdapterTrait.php @@ -4,11 +4,11 @@ namespace Migrations\Test\TestCase\Db\Adapter; use Migrations\Db\AlterInstructions; -use Migrations\Db\Literal; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; -use Migrations\Db\Table\Table; +use Migrations\Db\Table\TableMetadata; trait DefaultAdapterTrait { @@ -44,7 +44,7 @@ public function hasTable(string $tableName): bool return false; } - public function createTable(Table $table, array $columns = [], array $indexes = []): void + public function createTable(TableMetadata $table, array $columns = [], array $indexes = []): void { } @@ -77,16 +77,11 @@ public function hasPrimaryKey(string $tableName, array|string $columns, ?string return false; } - public function hasForeignKey(string $tableName, array|string $columns, ?string $constraint = null): bool + public function hasForeignKey(string $tableName, $columns, ?string $constraint = null): bool { return false; } - public function getSqlType(Literal|string $type, ?int $limit = null): array - { - return []; - } - public function createDatabase(string $name, array $options = []): void { } @@ -108,7 +103,7 @@ public function disconnect(): void { } - protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions + protected function getAddColumnInstructions(TableMetadata $table, Column $column): AlterInstructions { return new AlterInstructions(); } @@ -128,7 +123,7 @@ protected function getDropColumnInstructions(string $tableName, string $columnNa return new AlterInstructions(); } - protected function getAddIndexInstructions(Table $table, Index $index): AlterInstructions + protected function getAddIndexInstructions(TableMetadata $table, Index $index): AlterInstructions { return new AlterInstructions(); } @@ -143,7 +138,7 @@ protected function getDropIndexByNameInstructions(string $tableName, string $ind return new AlterInstructions(); } - protected function getAddForeignKeyInstructions(Table $table, ForeignKey $foreignKey): AlterInstructions + protected function getAddForeignKeyInstructions(TableMetadata $table, ForeignKey $foreignKey): AlterInstructions { return new AlterInstructions(); } @@ -168,12 +163,27 @@ protected function getRenameTableInstructions(string $tableName, string $newTabl return new AlterInstructions(); } - protected function getChangePrimaryKeyInstructions(Table $table, array|string|null $newColumns): AlterInstructions + protected function getChangePrimaryKeyInstructions(TableMetadata $table, array|string|null $newColumns): AlterInstructions + { + return new AlterInstructions(); + } + + protected function getChangeCommentInstructions(TableMetadata $table, ?string $newComment): AlterInstructions + { + return new AlterInstructions(); + } + + protected function getCheckConstraints(string $tableName): array + { + return []; + } + + protected function getAddCheckConstraintInstructions(TableMetadata $table, CheckConstraint $checkConstraint): AlterInstructions { return new AlterInstructions(); } - protected function getChangeCommentInstructions(Table $table, ?string $newComment): AlterInstructions + protected function getDropCheckConstraintInstructions(string $tableName, string $constraintName): AlterInstructions { return new AlterInstructions(); } diff --git a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php index f0d3a3bcc..9687db82e 100644 --- a/tests/TestCase/Db/Adapter/MysqlAdapterTest.php +++ b/tests/TestCase/Db/Adapter/MysqlAdapterTest.php @@ -8,22 +8,23 @@ use Cake\Console\TestSuite\StubConsoleOutput; use Cake\Core\Configure; use Cake\Database\Connection; +use Cake\Database\Driver\Mysql; use Cake\Datasource\ConnectionManager; use InvalidArgumentException; -use Migrations\Db\Adapter\AdapterInterface; use Migrations\Db\Adapter\MysqlAdapter; -use Migrations\Db\Adapter\UnsupportedColumnTypeException; use Migrations\Db\Literal; use Migrations\Db\Table; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; +use Migrations\Db\Table\Partition; use PDO; use PDOException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; -use ReflectionClass; +use RuntimeException; class MysqlAdapterTest extends TestCase { @@ -93,6 +94,13 @@ private function usingMysql8(): bool && version_compare($version, '10.0.0', '<'); } + private function usingMariaDb(): bool + { + $version = $this->adapter->getConnection()->getDriver()->version(); + + return str_contains($version, 'MariaDB') || version_compare($version, '10.0.0', '>='); + } + private function usingMariaDbWithUuid(): bool { $version = $this->adapter->getConnection()->getDriver()->version(); @@ -118,6 +126,11 @@ public function testCreatingTheSchemaTableOnConnect() public function testSchemaTableIsCreatedWithPrimaryKey() { + // Skip for unified table mode since schema structure is different + if (Configure::read('Migrations.legacyTables') === false) { + $this->markTestSkipped('Unified table has different primary key structure'); + } + $this->adapter->connect(); new Table($this->adapter->getSchemaTableName(), [], $this->adapter); $this->assertTrue($this->adapter->hasIndex($this->adapter->getSchemaTableName(), ['version'])); @@ -299,7 +312,7 @@ public function testCreateTableWithMultiplePrimaryKeys() ->addColumn('tag_id', 'integer', ['null' => false]) ->save(); $this->assertTrue($this->adapter->hasIndex('table1', ['user_id', 'tag_id'])); - $this->assertTrue($this->adapter->hasIndex('table1', ['USER_ID', 'tag_id'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['USER_ID', 'tag_id'])); $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_id'])); $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_email'])); } @@ -448,7 +461,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($row['Collation'], $this->getDefaultCollation()); + $this->assertContains($row['Collation'], ['utf8mb4_general_ci', 'utf8mb4_0900_ai_ci', 'utf8mb4_unicode_ci']); } public function testCreateTableWithLatin1Collate() @@ -821,38 +834,6 @@ public function testIntegerColumnTypes($phinx_type, $options, $sql_type, $width, $this->assertEquals($type, $rows[1]['Type']); } - /** - * Test that migrations still supports the `double` type but - * as an alias for a float/double column which cake/database provides. - */ - public function testAddDoubleDefaultSignedCompat(): void - { - $table = new Table('table1', [], $this->adapter); - $table->save(); - $this->assertFalse($table->hasColumn('user_id')); - $table->addColumn('foo', 'double') - ->save(); - $rows = $this->adapter->fetchAll('SHOW FULL COLUMNS FROM table1'); - $this->assertEquals('double', $rows[1]['Type']); - $this->assertEquals('YES', $rows[1]['Null']); - } - - /** - * Test that migrations still supports the `double` type but - * as an alias for a float column which cake/database provides. - */ - public function testAddDoubleDefaultSignedCompatWithUnsigned(): void - { - $table = new Table('table1', [], $this->adapter); - $table->save(); - $this->assertFalse($table->hasColumn('user_id')); - $table->addColumn('foo', 'double', ['signed' => false]) - ->save(); - $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM table1'); - $this->assertEquals('double unsigned', $rows[1]['Type']); - $this->assertEquals('YES', $rows[1]['Null']); - } - public function testAddStringColumnWithSignedEqualsFalse(): void { $table = new Table('table1', [], $this->adapter); @@ -1002,90 +983,262 @@ public function testChangeColumnDefaultToNull() $this->assertNull($rows[1]['Default']); } - public function testChangeColumnEnum() + public function testChangeColumnPreservesDefaultValue() { $table = new Table('t', [], $this->adapter); - $table->addColumn('column1', 'string') + $table->addColumn('column1', 'string', ['default' => 'original_default', 'null' => false, 'limit' => 100]) ->save(); - $this->assertTrue($this->adapter->hasColumn('t', 'column1')); - $table->changeColumn('column1', 'enum', ['values' => ['a', 'b']])->save(); - $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + // Use updateColumn which preserves by default + $table->updateColumn('column1', 'string', ['null' => true])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('original_default', $rows[1]['Default']); + $this->assertEquals('YES', $rows[1]['Null']); + $this->assertEquals('varchar(100)', $rows[1]['Type']); + } + + public function testChangeColumnPreservesDefaultValueWithDifferentType() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'integer', ['default' => 42, 'null' => false]) + ->save(); + + // Use updateColumn to preserve default when changing type + $table->updateColumn('column1', 'biginteger', [])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('42', $rows[1]['Default']); + $this->assertEquals('NO', $rows[1]['Null']); + } + + public function testChangeColumnCanExplicitlyOverrideDefault() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'original_default']) + ->save(); + + // Explicitly change the default + $table->changeColumn('column1', 'string', ['default' => 'new_default'])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('new_default', $rows[1]['Default']); + } + + public function testChangeColumnCanDisablePreserveUnspecified() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'original_default', 'limit' => 100]) + ->save(); + + // Disable preservation, default should be removed + $table->changeColumn('column1', 'string', ['null' => true, 'preserveUnspecified' => false])->save(); $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); $this->assertNull($rows[1]['Default']); - $this->assertEquals("enum('a','b')", $rows[1]['Type']); } - public static function sqlTypeIntConversionProvider() + public function testChangeColumnWithNullTypePreservesType() { - return [ - // tinyint - [AdapterInterface::PHINX_TYPE_TINY_INTEGER, null, 'tinyint', 4], - [AdapterInterface::PHINX_TYPE_TINY_INTEGER, 2, 'tinyint', 2], - [AdapterInterface::PHINX_TYPE_TINY_INTEGER, MysqlAdapter::INT_TINY, 'tinyint', 4], - // smallint - [AdapterInterface::PHINX_TYPE_SMALL_INTEGER, null, 'smallint', 6], - [AdapterInterface::PHINX_TYPE_SMALL_INTEGER, 3, 'smallint', 3], - [AdapterInterface::PHINX_TYPE_SMALL_INTEGER, MysqlAdapter::INT_SMALL, 'smallint', 6], - // medium - [AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, null, 'mediumint', 8], - [AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, 2, 'mediumint', 2], - [AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, MysqlAdapter::INT_MEDIUM, 'mediumint', 8], - // integer - [AdapterInterface::PHINX_TYPE_INTEGER, null, 'int', 11], - [AdapterInterface::PHINX_TYPE_INTEGER, 4, 'int', 4], - [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_TINY, 'tinyint', 4], - [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_SMALL, 'smallint', 6], - [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_MEDIUM, 'mediumint', 8], - [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_REGULAR, 'int', 11], - [AdapterInterface::PHINX_TYPE_INTEGER, MysqlAdapter::INT_BIG, 'bigint', 20], - // bigint - [AdapterInterface::PHINX_TYPE_BIG_INTEGER, null, 'bigint', 20], - [AdapterInterface::PHINX_TYPE_BIG_INTEGER, 4, 'bigint', 4], - [AdapterInterface::PHINX_TYPE_BIG_INTEGER, MysqlAdapter::INT_BIG, 'bigint', 20], - ]; + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test', 'limit' => 100]) + ->save(); + + // Use updateColumn with null type to preserve everything + $table->updateColumn('column1', null, ['null' => true])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('varchar(100)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); + $this->assertEquals('YES', $rows[1]['Null']); } - /** - * The second argument is not typed as MysqlAdapter::INT_BIG is a float, and all other values are integers - */ - #[DataProvider('sqlTypeIntConversionProvider')] - public function testGetSqlTypeIntegerConversion(string $type, $limit, string $expectedType, int $expectedLimit) + public function testChangeColumnWithNullTypeOnNonExistentColumnThrows() { - $sqlType = $this->adapter->getSqlType($type, $limit); - $this->assertSame($expectedType, $sqlType['name']); - $this->assertSame($expectedLimit, $sqlType['limit']); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Cannot preserve column type for 'nonexistent'"); + + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string')->save(); + + // Try to use null type on non-existent column + $table->changeColumn('nonexistent', null, ['null' => true])->save(); } - public function testLongTextColumn() + public function testUpdateColumnPreservesAttributes() { $table = new Table('t', [], $this->adapter); - $table->addColumn('column1', 'text', ['limit' => MysqlAdapter::TEXT_LONG]) + $table->addColumn('column1', 'string', ['default' => 'test', 'limit' => 100, 'null' => false]) ->save(); - $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertEquals('longtext', $sqlType['name']); + + // updateColumn should preserve by default + $table->updateColumn('column1', null, ['null' => true])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('varchar(100)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); + $this->assertEquals('YES', $rows[1]['Null']); } - public function testMediumTextColumn() + public function testChangeColumnDoesNotPreserveByDefault() { $table = new Table('t', [], $this->adapter); - $table->addColumn('column1', 'text', ['limit' => MysqlAdapter::TEXT_MEDIUM]) + $table->addColumn('column1', 'string', ['default' => 'test', 'limit' => 100]) ->save(); - $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertEquals('mediumtext', $sqlType['name']); + + // changeColumn should NOT preserve by default (backwards compatible) + $table->changeColumn('column1', 'string', ['null' => true])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + // Default should be lost + $this->assertNull($rows[1]['Default']); + $this->assertEquals('YES', $rows[1]['Null']); } - public function testTinyTextColumn() + public function testChangeColumnWithPreserveUnspecifiedTrue() { $table = new Table('t', [], $this->adapter); - $table->addColumn('column1', 'text', ['limit' => MysqlAdapter::TEXT_TINY]) + $table->addColumn('column1', 'string', ['default' => 'test', 'limit' => 100]) ->save(); - $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertEquals('tinytext', $sqlType['name']); + + // changeColumn with explicit preserveUnspecified => true + $table->changeColumn('column1', 'string', ['null' => true, 'preserveUnspecified' => true])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + // Default should be preserved + $this->assertEquals('test', $rows[1]['Default']); + $this->assertEquals('YES', $rows[1]['Null']); + } + + public function testUpdateColumnWithColumnObject() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test', 'limit' => 100, 'null' => false]) + ->save(); + + // Use updateColumn with a Column object + $newColumn = new Column(); + $newColumn->setName('column1') + ->setType('string') + ->setLimit(255) + ->setNull(true); + $table->updateColumn('column1', $newColumn)->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('varchar(255)', $rows[1]['Type']); + $this->assertEquals('YES', $rows[1]['Null']); + } + + public function testUpdateColumnWithColumnObjectAndOptionsThrows() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot specify options array when passing a Column object'); + + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['default' => 'test', 'limit' => 100]) + ->save(); + + // Passing both Column object and options array should throw an exception + $newColumn = new Column(); + $newColumn->setName('column1') + ->setType('string') + ->setLimit(200); + + $table->updateColumn('column1', $newColumn, ['limit' => 500]); + } + + public function testUpdateColumnWithTypeChangeToText() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['limit' => 100, 'default' => 'test']) + ->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('varchar(100)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); + + // Change type to text (limit doesn't apply to TEXT types) + $table->updateColumn('column1', 'text')->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + // TEXT type in MySQL doesn't have a length specifier + $this->assertEquals('text', $rows[1]['Type']); + // TEXT columns in MySQL quote the default value + $this->assertStringContainsString('test', $rows[1]['Default']); // Default should be preserved + } + + public function testUpdateColumnCanRemoveLengthConstraintWithoutChangingType() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['limit' => 100, 'default' => 'test']) + ->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('varchar(100)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); + + // Try to remove length constraint without changing type by passing length => null + // This tests the array_key_exists fix - isset() would fail here + $table->updateColumn('column1', 'string', ['length' => null])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + // Without explicit length, MySQL uses default varchar(255) + $this->assertEquals('varchar(255)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); // Default should be preserved + } + + public function testUpdateColumnCanRemoveScaleAndPrecision() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'decimal', ['precision' => 10, 'scale' => 2, 'default' => '123.45']) + ->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('decimal(10,2)', $rows[1]['Type']); + $this->assertEquals('123.45', $rows[1]['Default']); + + // Try to remove scale/precision by passing null + $table->updateColumn('column1', 'decimal', ['precision' => null, 'scale' => null])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + // Without explicit precision/scale, MySQL uses default decimal(10,0) + $this->assertEquals('decimal(10,0)', $rows[1]['Type']); + $this->assertEquals('123', $rows[1]['Default']); // Default should be preserved (truncated to integer) + } + + public function testUpdateColumnCanRemoveComment() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string', ['limit' => 100, 'comment' => 'Original comment', 'default' => 'test']) + ->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertEquals('varchar(100)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); + // MySQL doesn't show comments in SHOW COLUMNS, but we can verify it was set + + // Try to remove comment by passing null + $table->updateColumn('column1', 'string', ['comment' => null])->save(); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + // Verify limit and default are preserved + $this->assertEquals('varchar(100)', $rows[1]['Type']); + $this->assertEquals('test', $rows[1]['Default']); + } + + public function testChangeColumnEnum() + { + $table = new Table('t', [], $this->adapter); + $table->addColumn('column1', 'string') + ->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + + $table->changeColumn('column1', 'enum', ['values' => ['a', 'b']])->save(); + $this->assertTrue($this->adapter->hasColumn('t', 'column1')); + + $rows = $this->adapter->fetchAll('SHOW COLUMNS FROM t'); + $this->assertNull($rows[1]['Default']); + $this->assertEquals("enum('a','b')", $rows[1]['Type']); } public static function binaryToBlobAutomaticConversionData() @@ -1114,8 +1267,7 @@ public function testBinaryToBlobAutomaticConversion(?int $limit, string $expecte $table->addColumn('column1', 'binary', ['limit' => $limit]) ->save(); $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertSame($expectedType, $sqlType['name']); + $this->assertSame($expectedType, $columns[1]->getType()); $this->assertSame($expectedLimit, $columns[1]->getLimit()); } @@ -1145,8 +1297,7 @@ public function testVarbinaryToBlobAutomaticConversion(?int $limit, string $expe $table->addColumn('column1', 'varbinary', ['limit' => $limit]) ->save(); $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertSame($expectedType, $sqlType['name']); + $this->assertSame($expectedType, $columns[1]->getType()); $this->assertSame($expectedLimit, $columns[1]->getLimit()); } @@ -1186,11 +1337,10 @@ public static function blobColumnsData() public function testblobColumns(string $type, string $expectedType, ?int $limit, ?int $expectedLimit) { $table = new Table('t', [], $this->adapter); - $table->addColumn('column1', $type, ['limit' => $limit]) + $table->addColumn('blob_col', $type, ['limit' => $limit]) ->save(); $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertSame($expectedType, $sqlType['name']); + $this->assertSame($expectedType, $columns[1]->getType()); $this->assertSame($expectedLimit, $columns[1]->getLimit()); } @@ -1222,98 +1372,10 @@ public function testBlobRoundTrip(string $type, ?int $limit, string $expectedTyp $this->assertSame($expectedType, $blobColumn->getType(), 'Type mismatch after round-trip'); $this->assertSame($expectedLimit, $blobColumn->getLimit(), 'Limit mismatch after round-trip'); - // Verify that the SQL type is correct - $sqlType = $this->adapter->getSqlType($blobColumn->getType(), $blobColumn->getLimit()); - $this->assertSame($type, $sqlType['name']); - // Clean up $this->adapter->dropTable('blob_round_trip_test'); } - public function testBigIntegerColumn() - { - $table = new Table('t', [], $this->adapter); - $table->addColumn('column1', 'integer', ['limit' => MysqlAdapter::INT_BIG]) - ->save(); - $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertEquals('bigint', $sqlType['name']); - } - - public function testMediumIntegerColumn() - { - $table = new Table('t', [], $this->adapter); - $table->addColumn('column1', 'integer', ['limit' => MysqlAdapter::INT_MEDIUM]) - ->save(); - $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertEquals('int', $sqlType['name']); - } - - public function testSmallIntegerColumn() - { - $table = new Table('t', [], $this->adapter); - $table->addColumn('column1', 'integer', ['limit' => MysqlAdapter::INT_SMALL]) - ->save(); - $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertEquals('int', $sqlType['name']); - } - - public function testTinyIntegerColumn() - { - $table = new Table('t', [], $this->adapter); - $table->addColumn('column1', 'integer', ['limit' => MysqlAdapter::INT_TINY]) - ->save(); - $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertEquals('int', $sqlType['name']); - } - - public function testDatetimeColumn() - { - $this->adapter->connect(); - $version = $this->adapter->getConnection()->getDriver()->version(); - if (version_compare($version, '5.6.4') === -1) { - $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); - } - $table = new Table('t', [], $this->adapter); - $table->addColumn('column1', 'datetime')->save(); - $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertNull($sqlType['limit']); - } - - public function testDatetimeColumnLimit() - { - $this->adapter->connect(); - $version = $this->adapter->getConnection()->getDriver()->version(); - if (version_compare($version, '5.6.4') === -1) { - $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); - } - $limit = 6; - $table = new Table('t', [], $this->adapter); - $table->addColumn('column1', 'datetime', ['limit' => $limit])->save(); - $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertEquals($limit, $sqlType['limit']); - } - - public function testTimestampColumnLimit() - { - $this->adapter->connect(); - $version = $this->adapter->getConnection()->getDriver()->version(); - if (version_compare($version, '5.6.4') === -1) { - $this->markTestSkipped('Cannot test datetime limit on versions less than 5.6.4'); - } - $limit = 1; - $table = new Table('t', [], $this->adapter); - $table->addColumn('column1', 'timestamp', ['limit' => $limit])->save(); - $columns = $table->getColumns(); - $sqlType = $this->adapter->getSqlType($columns[1]->getType(), $columns[1]->getLimit()); - $this->assertEquals($limit, $sqlType['limit']); - } - public function testTimestampInvalidLimit() { $this->adapter->connect(); @@ -1350,6 +1412,7 @@ public static function columnsProvider() ['column6', 'float', []], ['column7', 'decimal', []], ['decimal_precision_scale', 'decimal', ['precision' => 10, 'scale' => 2]], + ['decimal_precision_scale_zero', 'decimal', ['precision' => 65, 'scale' => 0]], ['decimal_limit', 'decimal', ['limit' => 10]], ['decimal_precision', 'decimal', ['precision' => 10]], ['column8', 'datetime', []], @@ -1891,8 +1954,8 @@ public static function provideForeignKeysToCheck() ['create table t(a int, b int, foreign key(a,b) references other(a,b))', 'a', false], ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['a', 'b'], true], ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['b', 'a'], false], - ['create table t(a int, `B` int, foreign key(a,`B`) references other(a,b))', ['a', 'b'], true], - ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['a', 'B'], true], + ['create table t(a int, `B` int, foreign key(a,`B`) references other(a,b))', ['a', 'b'], false], + ['create table t(a int, `B` int, foreign key(a,`B`) references other(a,b))', ['a', 'B'], true], ['create table t(a int, b int, c int, foreign key(a,b,c) references other(a,b,c))', ['a', 'b'], false], ['create table t(a int, foreign key(a) references other(a))', ['a', 'b'], false], ['create table t(a int, b int, foreign key(a) references other(a), foreign key(b) references other(b))', ['a', 'b'], false], @@ -2223,19 +2286,19 @@ public function testDumpCreateTable() ->addColumn('column3', 'string', ['default' => 'test', 'null' => false]) ->save(); - $collation = $this->getDefaultCollation(); - - $expectedOutput = <<out->messages()); - $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create table query to the output'); + // MySQL version affects default collation (8.0.0+ uses utf8mb4_0900_ai_ci, older uses utf8mb4_general_ci) + $this->assertMatchesRegularExpression( + '/CREATE TABLE `table1` \(`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, `column1` VARCHAR\(255\) NOT NULL, `column2` INTEGER, `column3` VARCHAR\(255\) NOT NULL DEFAULT \'test\', PRIMARY KEY \(`id`\)\) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_(0900_ai_ci|general_ci);/', + $actualOutput, + 'Passing the --dry-run option does not dump create table query to the output', + ); } /** * Creates the table "table1". - * Then sets phinx to dry run mode and inserts a record. - * Asserts that phinx outputs the insert statement and doesn't insert a record. + * Then enables dry run mode and inserts a record. + * Asserts that the insert statement is output and doesn't insert a record. */ public function testDumpInsert() { @@ -2278,8 +2341,8 @@ public function testDumpInsert() /** * Creates the table "table1". - * Then sets phinx to dry run mode and inserts some records. - * Asserts that phinx outputs the insert statement and doesn't insert any record. + * Then enables dry run mode and inserts some records. + * Asserts that output contains the insert statement and doesn't insert any record. */ public function testDumpBulkinsert() { @@ -2330,17 +2393,15 @@ public function testDumpCreateTableAndThenInsert() 'column2' => 1, ])->save(); - $collation = $this->getDefaultCollation(); - - $expectedOutput = <<out->messages()); // Add this to be LF - CR/LF systems independent - $expectedOutput = preg_replace('~\R~u', '', $expectedOutput); $actualOutput = preg_replace('~\R~u', '', $actualOutput); - $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create and then insert table queries to the output'); + // MySQL version affects default collation (8.0.0+ uses utf8mb4_0900_ai_ci, older uses utf8mb4_general_ci) + $this->assertMatchesRegularExpression( + '/CREATE TABLE `table1` \(`column1` VARCHAR\(255\) NOT NULL, `column2` INTEGER, PRIMARY KEY \(`column1`\)\) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_(0900_ai_ci|general_ci);INSERT INTO `table1` \(`column1`, `column2`\) VALUES \(\'id1\', 1\);/', + $actualOutput, + 'Passing the --dry-run option does not dump create and then insert table queries to the output', + ); } /** @@ -2419,10 +2480,10 @@ public function testQueryWithParams() public static function geometryTypeProvider() { return [ - [MysqlAdapter::PHINX_TYPE_GEOMETRY, 'POINT(0 0)'], - [MysqlAdapter::PHINX_TYPE_POINT, 'POINT(0 0)'], - [MysqlAdapter::PHINX_TYPE_LINESTRING, 'LINESTRING(30 10,10 30,40 40)'], - [MysqlAdapter::PHINX_TYPE_POLYGON, 'POLYGON((30 10,40 40,20 40,10 20,30 10))'], + [MysqlAdapter::TYPE_GEOMETRY, 'POINT(0 0)'], + [MysqlAdapter::TYPE_POINT, 'POINT(0 0)'], + [MysqlAdapter::TYPE_LINESTRING, 'LINESTRING(30 10,10 30,40 40)'], + [MysqlAdapter::TYPE_POLYGON, 'POLYGON((30 10,40 40,20 40,10 20,30 10))'], ]; } @@ -2472,35 +2533,15 @@ public function testGeometrySridThrowsInsertDifferentSrid($type, $geom) $this->adapter->execute("INSERT INTO table1 (`geom`) VALUES (ST_GeomFromText('{$geom}', 4322))"); } - /** - * Small check to verify if specific Mysql constants are handled in AdapterInterface - * - * @see https://github.com/cakephp/migrations/issues/359 - */ - public function testMysqlBlobsConstants() - { - $reflector = new ReflectionClass(AdapterInterface::class); - - $validTypes = array_filter($reflector->getConstants(), function ($constant) { - return substr($constant, 0, strlen('PHINX_TYPE_')) === 'PHINX_TYPE_'; - }, ARRAY_FILTER_USE_KEY); - - $this->assertTrue(in_array('tinyblob', $validTypes, true)); - $this->assertTrue(in_array('blob', $validTypes, true)); - $this->assertTrue(in_array('mediumblob', $validTypes, true)); - $this->assertTrue(in_array('longblob', $validTypes, true)); - } - public static function defaultsCastAsExpressions() { return [ - [MysqlAdapter::PHINX_TYPE_BLOB, 'abc'], - [MysqlAdapter::PHINX_TYPE_JSON, '{"a": true}'], - [MysqlAdapter::PHINX_TYPE_TEXT, 'abc'], - [MysqlAdapter::PHINX_TYPE_GEOMETRY, 'POINT(0 0)'], - [MysqlAdapter::PHINX_TYPE_POINT, 'POINT(0 0)'], - [MysqlAdapter::PHINX_TYPE_LINESTRING, 'LINESTRING(30 10,10 30,40 40)'], - [MysqlAdapter::PHINX_TYPE_POLYGON, 'POLYGON((30 10,40 40,20 40,10 20,30 10))'], + [MysqlAdapter::TYPE_JSON, '{"a": true}'], + [MysqlAdapter::TYPE_TEXT, 'abc'], + [MysqlAdapter::TYPE_GEOMETRY, 'POINT(0 0)'], + [MysqlAdapter::TYPE_POINT, 'POINT(0 0)'], + [MysqlAdapter::TYPE_LINESTRING, 'LINESTRING(30 10,10 30,40 40)'], + [MysqlAdapter::TYPE_POLYGON, 'POLYGON((30 10,40 40,20 40,10 20,30 10))'], ]; } @@ -2515,11 +2556,11 @@ public static function defaultsCastAsExpressions() public function testDefaultsCastAsExpressionsForCertainTypes(string $type, string $default): void { if ( - $this->usingMariaDbWithUuid() && in_array($type, [ - MysqlAdapter::PHINX_TYPE_GEOMETRY, - MysqlAdapter::PHINX_TYPE_POINT, - MysqlAdapter::PHINX_TYPE_LINESTRING, - MysqlAdapter::PHINX_TYPE_POLYGON, + $this->usingMariaDb() && in_array($type, [ + MysqlAdapter::TYPE_GEOMETRY, + MysqlAdapter::TYPE_POINT, + MysqlAdapter::TYPE_LINESTRING, + MysqlAdapter::TYPE_POLYGON, ]) ) { $this->markTestSkipped('GIS is broken with MariaDB'); @@ -2528,7 +2569,8 @@ public function testDefaultsCastAsExpressionsForCertainTypes(string $type, strin $this->adapter->connect(); $table = new Table('table1', ['id' => false], $this->adapter); - if (!$this->usingMysql8() && !$this->usingMariaDbWithUuid()) { + // MySQL 8.0+ and MariaDB 10.2+ support defaults for JSON/TEXT + if (!$this->usingMysql8() && !$this->usingMariaDb()) { $this->expectException(PDOException::class); } $table @@ -2539,11 +2581,12 @@ public function testDefaultsCastAsExpressionsForCertainTypes(string $type, strin $this->assertCount(1, $columns); $this->assertSame('col_1', $columns[0]->getName()); - if ($this->usingMariaDbWithUuid()) { - $this->assertSame("'{$default}'", $columns[0]->getDefault()); - } else { - $this->assertSame($default, $columns[0]->getDefault()); + $actualDefault = $columns[0]->getDefault(); + // Normalize quote handling - both MariaDB and MySQL 8.0.13+ may return defaults with quotes + if (str_starts_with($actualDefault, "'") && str_ends_with($actualDefault, "'")) { + $actualDefault = substr($actualDefault, 1, -1); } + $this->assertSame($default, $actualDefault); } public function testCreateTableWithPrecisionCurrentTimestamp() @@ -2565,48 +2608,880 @@ public function testCreateTableWithPrecisionCurrentTimestamp() $this->assertEqualsIgnoringCase('CURRENT_TIMESTAMP(3)', $colDef['COLUMN_DEFAULT']); } - public static function integerDataTypesSQLProvider() + public function testAddCheckConstraint() { - return [ - // Types without a width should always have a null limit - ['bigint', ['name' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => null, 'scale' => null]], - ['int', ['name' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], - ['mediumint', ['name' => AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, 'limit' => null, 'scale' => null]], - ['smallint', ['name' => AdapterInterface::PHINX_TYPE_SMALL_INTEGER, 'limit' => null, 'scale' => null]], - ['tinyint', ['name' => AdapterInterface::PHINX_TYPE_TINY_INTEGER, 'limit' => null, 'scale' => null]], - - // Types which include a width should always have that as their limit - ['bigint(20)', ['name' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => 20, 'scale' => null]], - ['bigint(10)', ['name' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => 10, 'scale' => null]], - ['bigint(1) unsigned', ['name' => AdapterInterface::PHINX_TYPE_BIG_INTEGER, 'limit' => 1, 'scale' => null]], - ['int(11)', ['name' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => 11, 'scale' => null]], - ['int(10) unsigned', ['name' => AdapterInterface::PHINX_TYPE_INTEGER, 'limit' => 10, 'scale' => null]], - ['mediumint(6)', ['name' => AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, 'limit' => 6, 'scale' => null]], - ['mediumint(8) unsigned', ['name' => AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER, 'limit' => 8, 'scale' => null]], - ['smallint(2)', ['name' => AdapterInterface::PHINX_TYPE_SMALL_INTEGER, 'limit' => 2, 'scale' => null]], - ['smallint(5) unsigned', ['name' => AdapterInterface::PHINX_TYPE_SMALL_INTEGER, 'limit' => 5, 'scale' => null]], - ['tinyint(3) unsigned', ['name' => AdapterInterface::PHINX_TYPE_TINY_INTEGER, 'limit' => 3, 'scale' => null]], - ['tinyint(4)', ['name' => AdapterInterface::PHINX_TYPE_TINY_INTEGER, 'limit' => 4, 'scale' => null]], - - // Special case for commonly used boolean type - ['tinyint(1)', ['name' => AdapterInterface::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], - ]; + $table = new Table('check_table', [], $this->adapter); + $table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->create(); + + $checkConstraint = new CheckConstraint('price_positive', 'price > 0'); + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); + + $this->assertTrue($this->adapter->hasCheckConstraint('check_table', 'price_positive')); } - #[DataProvider('integerDataTypesSQLProvider')] - public function testGetPhinxTypeFromSQLDefinition(string $sqlDefinition, array $expectedResponse) + public function testAddCheckConstraintWithAutoGeneratedName() { - $result = $this->adapter->getPhinxType($sqlDefinition); + $table = new Table('check_table2', [], $this->adapter); + $table->addColumn('age', 'integer') + ->create(); + + $checkConstraint = new CheckConstraint('', 'age >= 18'); + + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); - $this->assertSame($expectedResponse['name'], $result['name'], "Type mismatch - got '{$result['name']}' when expecting '{$expectedResponse['name']}'"); - $this->assertSame($expectedResponse['limit'], $result['limit'], "Field upper boundary mismatch - got '{$result['limit']}' when expecting '{$expectedResponse['limit']}'"); + $driver = $this->adapter->getConnection()->getDriver(); + assert($driver instanceof Mysql); + + $dialect = $driver->schemaDialect(); + $constraints = $dialect->describeCheckConstraints('check_table2'); + $this->assertCount(1, $constraints); + $expected = $driver->isMariaDb() ? 'CONSTRAINT_1' : 'check_table2_chk_'; + $this->assertStringContainsString($expected, $constraints[0]['name']); } - public function testGetSqlType() + public function testHasCheckConstraint() { - if (!$this->usingMariaDbWithUuid()) { - $this->expectException(UnsupportedColumnTypeException::class); - } - $this->assertSame(['name' => 'uuid'], $this->adapter->getSqlType('nativeuuid')); + $table = new Table('check_table3', [], $this->adapter); + $table->addColumn('quantity', 'integer') + ->create(); + + $checkConstraint = new CheckConstraint('quantity_positive', 'quantity > 0'); + $this->assertFalse($this->adapter->hasCheckConstraint('check_table3', 'quantity_positive')); + + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); + + $this->assertTrue($this->adapter->hasCheckConstraint('check_table3', 'quantity_positive')); + } + + public function testDropCheckConstraint() + { + $table = new Table('check_table4', [], $this->adapter); + $table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->create(); + + $checkConstraint = new CheckConstraint('price_check', 'price BETWEEN 0 AND 1000'); + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); + $this->assertTrue($this->adapter->hasCheckConstraint('check_table4', 'price_check')); + + $this->adapter->dropCheckConstraint('check_table4', 'price_check'); + $this->assertFalse($this->adapter->hasCheckConstraint('check_table4', 'price_check')); + } + + public function testCheckConstraintWithComplexExpression() + { + $table = new Table('check_table5', [], $this->adapter); + $table->addColumn('email', 'string', ['limit' => 255]) + ->addColumn('status', 'string', ['limit' => 20]) + ->create(); + + $checkConstraint = new CheckConstraint( + 'status_valid', + "status IN ('active', 'inactive', 'pending')", + ); + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); + $this->assertTrue($this->adapter->hasCheckConstraint('check_table5', 'status_valid')); + + // Verify the constraint is actually enforced + $quotedTableName = $this->adapter->getConnection()->getDriver()->quoteIdentifier('check_table5'); + $this->expectException(PDOException::class); + $this->adapter->execute("INSERT INTO {$quotedTableName} (email, status) VALUES ('test@example.com', 'invalid')"); + } + + /** + * Test that DECIMAL columns with scale=0 work correctly. + * + * This tests the fix for https://github.com/cakephp/phinx/pull/2377 + * In phinx, the boolean check `$column->getPrecision() && $column->getScale()` + * would fail when scale is 0 because 0 is falsy in PHP. + * + * The 5.x branch uses CakePHP's database layer instead of phinx, + * so we need to verify it handles scale=0 correctly. + */ + public function testDecimalWithScaleZero() + { + // Create table with DECIMAL(65,0) + $table = new Table('decimal_scale_zero_test', [], $this->adapter); + $table->addColumn('amount', 'decimal', ['precision' => 65, 'scale' => 0]) + ->create(); + + // Verify the column was created with correct precision and scale + $columns = $this->adapter->getColumns('decimal_scale_zero_test'); + $amountColumn = null; + foreach ($columns as $column) { + if ($column->getName() === 'amount') { + $amountColumn = $column; + break; + } + } + + $this->assertNotNull($amountColumn, 'Amount column should exist'); + $this->assertEquals('decimal', $amountColumn->getType()); + $this->assertEquals(65, $amountColumn->getPrecision()); + $this->assertEquals(0, $amountColumn->getScale(), 'Scale should be 0, not null'); + + // Verify the actual MySQL column definition + $result = $this->adapter->fetchRow('SHOW CREATE TABLE `decimal_scale_zero_test`'); + $createTableSql = $result['Create Table']; + + // The CREATE TABLE should contain DECIMAL(65,0) - case insensitive + $this->assertMatchesRegularExpression( + '/decimal\(65,0\)/i', + $createTableSql, + 'CREATE TABLE should contain DECIMAL(65,0) with scale=0 properly defined', + ); + } + + public function testInsertOrSkipWithDuplicates() + { + $table = new Table('users', [], $this->adapter); + $table->addColumn('email', 'string', ['limit' => 255]) + ->addColumn('name', 'string') + ->addIndex('email', ['unique' => true]) + ->create(); + + // First insert + $table->insertOrSkip([ + ['email' => 'test@example.com', 'name' => 'John'], + ])->save(); + + // Duplicate - should be skipped + $table->insertOrSkip([ + ['email' => 'test@example.com', 'name' => 'Jane'], + ])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM users'); + $this->assertCount(1, $rows); + $this->assertEquals('John', $rows[0]['name']); + } + + public function testInsertModeResetsAfterInsertOrSkip() + { + $table = new Table('users', [], $this->adapter); + $table->addColumn('email', 'string', ['limit' => 255]) + ->addColumn('name', 'string') + ->addIndex('email', ['unique' => true]) + ->create(); + + // First insert with insertOrSkip + $table->insertOrSkip([ + ['email' => 'test@example.com', 'name' => 'John'], + ])->save(); + + // Now use regular insert with duplicate - should throw exception + $this->expectException(PDOException::class); + $table->insert([ + ['email' => 'test@example.com', 'name' => 'Jane'], + ])->save(); + } + + public function testBulkinsertOrSkipWithDuplicates() + { + $table = new Table('products', [], $this->adapter); + $table->addColumn('sku', 'string', ['limit' => 50]) + ->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->addIndex('sku', ['unique' => true]) + ->create(); + + // First bulk insert + $table->insertOrSkip([ + ['sku' => 'ABC123', 'price' => 10.50], + ['sku' => 'DEF456', 'price' => 20.00], + ])->save(); + + // Mix of new and duplicate - duplicates should be skipped + $table->insertOrSkip([ + ['sku' => 'ABC123', 'price' => 99.99], // Duplicate + ['sku' => 'GHI789', 'price' => 30.00], // New + ])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM products ORDER BY sku'); + $this->assertCount(3, $rows); + $this->assertEquals('10.50', $rows[0]['price']); // Original price preserved + $this->assertEquals('20.00', $rows[1]['price']); + $this->assertEquals('30.00', $rows[2]['price']); + } + + public function testInsertOrSkipWithoutDuplicates() + { + $table = new Table('categories', [], $this->adapter); + $table->addColumn('name', 'string') + ->create(); + + // Should work like normal insert + $table->insertOrSkip([ + ['name' => 'Category 1'], + ['name' => 'Category 2'], + ])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM categories'); + $this->assertCount(2, $rows); + } + + public function testAddColumnWithAlgorithmInstant() + { + $table = new Table('users', [], $this->adapter); + $table->addColumn('email', 'string') + ->create(); + + $table->addColumn('status', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + ])->update(); + + $this->assertTrue($this->adapter->hasColumn('users', 'status')); + } + + public function testAddColumnWithAlgorithmAndLock() + { + $table = new Table('products', [], $this->adapter); + $table->addColumn('name', 'string') + ->create(); + + // Use ALGORITHM=INPLACE with LOCK=NONE (INSTANT can't have explicit locks) + $table->addColumn('price', 'decimal', [ + 'precision' => 10, + 'scale' => 2, + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_NONE, + ])->update(); + + $this->assertTrue($this->adapter->hasColumn('products', 'price')); + } + + public function testChangeColumnWithAlgorithm() + { + $table = new Table('items', [], $this->adapter); + $table->addColumn('description', 'string', ['limit' => 100]) + ->create(); + + $table->changeColumn('description', 'string', [ + 'limit' => 255, + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_SHARED, + ])->update(); + + $columns = $this->adapter->getColumns('items'); + foreach ($columns as $column) { + if ($column->getName() === 'description') { + $this->assertEquals(255, $column->getLimit()); + } + } + } + + public function testBatchedOperationsWithSameAlgorithm() + { + $table = new Table('batch_test', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $table->addColumn('col2', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + ]) + ->addColumn('col3', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + ]) + ->update(); + + $this->assertTrue($this->adapter->hasColumn('batch_test', 'col2')); + $this->assertTrue($this->adapter->hasColumn('batch_test', 'col3')); + } + + public function testBatchedOperationsWithConflictingAlgorithmsThrowsException() + { + $table = new Table('conflict_test', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Conflicting algorithm specifications'); + + $table->addColumn('col2', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + ]) + ->addColumn('col3', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_COPY, + ]) + ->update(); + } + + public function testBatchedOperationsWithConflictingLocksThrowsException() + { + $table = new Table('lock_conflict_test', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Conflicting lock specifications'); + + $table->addColumn('col2', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_NONE, + ]) + ->addColumn('col3', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INPLACE, + 'lock' => MysqlAdapter::LOCK_SHARED, + ]) + ->update(); + } + + public function testInvalidAlgorithmThrowsException() + { + $table = new Table('invalid_algo', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid algorithm'); + + $table->addColumn('col2', 'string', [ + 'algorithm' => 'INVALID', + ])->update(); + } + + public function testInvalidLockThrowsException() + { + $table = new Table('invalid_lock', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid lock'); + + $table->addColumn('col2', 'string', [ + 'lock' => 'INVALID', + ])->update(); + } + + public function testAlgorithmInstantWithExplicitLockThrowsException() + { + $table = new Table('instant_lock_test', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ALGORITHM=INSTANT cannot be combined with LOCK=NONE'); + + $table->addColumn('col2', 'string', [ + 'null' => true, + 'algorithm' => MysqlAdapter::ALGORITHM_INSTANT, + 'lock' => MysqlAdapter::LOCK_NONE, + ])->update(); + } + + public function testAlgorithmConstantsAreDefined() + { + $this->assertEquals('DEFAULT', MysqlAdapter::ALGORITHM_DEFAULT); + $this->assertEquals('INSTANT', MysqlAdapter::ALGORITHM_INSTANT); + $this->assertEquals('INPLACE', MysqlAdapter::ALGORITHM_INPLACE); + $this->assertEquals('COPY', MysqlAdapter::ALGORITHM_COPY); + } + + public function testLockConstantsAreDefined() + { + $this->assertEquals('DEFAULT', MysqlAdapter::LOCK_DEFAULT); + $this->assertEquals('NONE', MysqlAdapter::LOCK_NONE); + $this->assertEquals('SHARED', MysqlAdapter::LOCK_SHARED); + $this->assertEquals('EXCLUSIVE', MysqlAdapter::LOCK_EXCLUSIVE); + } + + public function testAlgorithmWithMixedCase() + { + $table = new Table('mixed_case', [], $this->adapter); + $table->addColumn('col1', 'string') + ->create(); + + // Should work with lowercase (use INPLACE with LOCK, not INSTANT) + $table->addColumn('col2', 'string', [ + 'null' => true, + 'algorithm' => 'inplace', + 'lock' => 'none', + ])->update(); + + $this->assertTrue($this->adapter->hasColumn('mixed_case', 'col2')); + } + + public function testInsertOrUpdateWithDuplicates() + { + $table = new Table('currencies', [], $this->adapter); + $table->addColumn('code', 'string', ['limit' => 3]) + ->addColumn('rate', 'decimal', ['precision' => 10, 'scale' => 4]) + ->addIndex('code', ['unique' => true]) + ->create(); + + // First insert + $table->insertOrUpdate([ + ['code' => 'USD', 'rate' => 1.0000], + ['code' => 'EUR', 'rate' => 0.9000], + ], ['rate'], ['code'])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM currencies ORDER BY code'); + $this->assertCount(2, $rows); + $this->assertEquals('0.9000', $rows[0]['rate']); // EUR + $this->assertEquals('1.0000', $rows[1]['rate']); // USD + + // Update rates - should update existing rows + $table->insertOrUpdate([ + ['code' => 'USD', 'rate' => 1.0500], + ['code' => 'EUR', 'rate' => 0.9234], + ['code' => 'GBP', 'rate' => 0.7800], // New row + ], ['rate'], ['code'])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM currencies ORDER BY code'); + $this->assertCount(3, $rows); + $this->assertEquals('0.9234', $rows[0]['rate']); // EUR updated + $this->assertEquals('0.7800', $rows[1]['rate']); // GBP new + $this->assertEquals('1.0500', $rows[2]['rate']); // USD updated + } + + public function testInsertOrUpdateWithMultipleUpdateColumns() + { + $table = new Table('products', [], $this->adapter); + $table->addColumn('sku', 'string', ['limit' => 50]) + ->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->addColumn('stock', 'integer') + ->addIndex('sku', ['unique' => true]) + ->create(); + + // First insert + $table->insertOrUpdate([ + ['sku' => 'ABC123', 'price' => 10.00, 'stock' => 100], + ], ['price', 'stock'], ['sku'])->save(); + + // Update both price and stock + $table->insertOrUpdate([ + ['sku' => 'ABC123', 'price' => 15.00, 'stock' => 50], + ], ['price', 'stock'], ['sku'])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM products'); + $this->assertCount(1, $rows); + $this->assertEquals('15.00', $rows[0]['price']); + $this->assertEquals(50, $rows[0]['stock']); + } + + public function testInsertOrUpdateModeResetsAfterSave() + { + $table = new Table('items', [], $this->adapter); + $table->addColumn('code', 'string', ['limit' => 10]) + ->addColumn('name', 'string') + ->addIndex('code', ['unique' => true]) + ->create(); + + // Use insertOrUpdate + $table->insertOrUpdate([ + ['code' => 'ITEM1', 'name' => 'Item One'], + ], ['name'], ['code'])->save(); + + // Now use regular insert with duplicate - should throw exception + $this->expectException(PDOException::class); + $table->insert([ + ['code' => 'ITEM1', 'name' => 'Different Name'], + ])->save(); + } + + public function testCreateTableWithRangeColumnsPartitioning() + { + // MySQL requires RANGE COLUMNS for DATE columns + $table = new Table('partitioned_orders', ['id' => false, 'primary_key' => ['id', 'order_date']], $this->adapter); + $table->addColumn('id', 'integer') + ->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('pmax', 'MAXVALUE') + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_orders')); + $this->assertTrue($this->adapter->hasColumn('partitioned_orders', 'id')); + $this->assertTrue($this->adapter->hasColumn('partitioned_orders', 'order_date')); + } + + public function testCreateTableWithListColumnsPartitioning() + { + // MySQL requires LIST COLUMNS for STRING columns + $table = new Table('partitioned_customers', ['id' => false, 'primary_key' => ['id', 'region']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('region', 'string', ['limit' => 20]) + ->addColumn('name', 'string') + ->partitionBy(Partition::TYPE_LIST_COLUMNS, 'region') + ->addPartition('p_americas', ['US', 'CA', 'MX']) + ->addPartition('p_europe', ['UK', 'DE', 'FR']) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_customers')); + } + + public function testCreateTableWithHashPartitioning() + { + // MySQL requires partition column in primary key + $table = new Table('partitioned_sessions', ['id' => false, 'primary_key' => ['id', 'user_id']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('user_id', 'integer') + ->addColumn('data', 'text') + ->partitionBy(Partition::TYPE_HASH, 'user_id', ['count' => 4]) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_sessions')); + } + + public function testCreateTableWithKeyPartitioning() + { + $table = new Table('partitioned_cache', ['id' => false, 'primary_key' => ['cache_key']], $this->adapter); + $table->addColumn('cache_key', 'string', ['limit' => 255]) + ->addColumn('value', 'binary') + ->partitionBy(Partition::TYPE_KEY, 'cache_key', ['count' => 8]) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_cache')); + } + + public function testCreateTableWithRangePartitioningByInteger() + { + $table = new Table('partitioned_logs', ['id' => false, 'primary_key' => ['id']], $this->adapter); + $table->addColumn('id', 'biginteger') + ->addColumn('message', 'text') + ->partitionBy(Partition::TYPE_RANGE, 'id') + ->addPartition('p0', 1000000) + ->addPartition('p1', 2000000) + ->addPartition('pmax', 'MAXVALUE') + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_logs')); + } + + public function testCreateTableWithExpressionPartitioning() + { + $table = new Table('partitioned_events', ['id' => false, 'primary_key' => ['id', 'created_at']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('created_at', 'datetime') + ->partitionBy(Partition::TYPE_RANGE, Literal::from('YEAR(created_at)')) + ->addPartition('p2022', 2023) + ->addPartition('p2023', 2024) + ->addPartition('pmax', 'MAXVALUE') + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_events')); + } + + public function testAddSinglePartitionToExistingTable() + { + // Create a partitioned table with room to add more partitions + $table = new Table('partitioned_orders', ['id' => false, 'primary_key' => ['id', 'order_date']], $this->adapter); + $table->addColumn('id', 'integer') + ->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') + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_orders')); + + // Add a single partition to the existing table + $table = new Table('partitioned_orders', [], $this->adapter); + $table->addPartitionToExisting('p2024', '2025-01-01') + ->save(); + + // Verify the partition was added by inserting data that belongs in the new partition + $this->adapter->execute( + "INSERT INTO partitioned_orders (id, order_date, amount) VALUES (1, '2024-06-15', 100.00)", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_orders WHERE order_date = "2024-06-15"'); + $this->assertCount(1, $rows); + } + + public function testAddMultiplePartitionsToExistingTable() + { + // Create a partitioned table with room to add more partitions + $table = new Table('partitioned_sales', ['id' => false, 'primary_key' => ['id', 'sale_date']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('sale_date', 'date') + ->addColumn('amount', 'decimal', ['precision' => 10, 'scale' => 2]) + ->partitionBy(Partition::TYPE_RANGE_COLUMNS, 'sale_date') + ->addPartition('p2022', '2023-01-01') + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_sales')); + + // Add multiple partitions at once - this is the main test for the fix + // MySQL requires: ADD PARTITION (PARTITION p1 ..., PARTITION p2 ...) + // NOT: ADD PARTITION (...), ADD PARTITION (...) + $table = new Table('partitioned_sales', [], $this->adapter); + $table->addPartitionToExisting('p2023', '2024-01-01') + ->addPartitionToExisting('p2024', '2025-01-01') + ->addPartitionToExisting('p2025', '2026-01-01') + ->save(); + + // Verify all partitions were added by inserting data into each + $this->adapter->execute( + "INSERT INTO partitioned_sales (id, sale_date, amount) VALUES (1, '2023-06-15', 100.00)", + ); + $this->adapter->execute( + "INSERT INTO partitioned_sales (id, sale_date, amount) VALUES (2, '2024-06-15', 200.00)", + ); + $this->adapter->execute( + "INSERT INTO partitioned_sales (id, sale_date, amount) VALUES (3, '2025-06-15', 300.00)", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_sales'); + $this->assertCount(3, $rows); + } + + public function testDropSinglePartitionFromExistingTable() + { + // Create a partitioned table with multiple partitions + $table = new Table('partitioned_logs', ['id' => false, 'primary_key' => ['id']], $this->adapter); + $table->addColumn('id', 'biginteger') + ->addColumn('message', 'text') + ->partitionBy(Partition::TYPE_RANGE, 'id') + ->addPartition('p0', 1000000) + ->addPartition('p1', 2000000) + ->addPartition('pmax', 'MAXVALUE') + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_logs')); + + // Insert data into partition p0 + $this->adapter->execute( + "INSERT INTO partitioned_logs (id, message) VALUES (500, 'test message')", + ); + + // Drop the partition (this also removes the data) + $table = new Table('partitioned_logs', [], $this->adapter); + $table->dropPartition('p0') + ->save(); + + // Verify the data was removed with the partition + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_logs WHERE id = 500'); + $this->assertCount(0, $rows); + + // Verify the table still works by inserting into the next partition + $this->adapter->execute( + "INSERT INTO partitioned_logs (id, message) VALUES (1500000, 'another message')", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_logs WHERE id = 1500000'); + $this->assertCount(1, $rows); + } + + public function testDropMultiplePartitionsFromExistingTable() + { + // Create a partitioned table with multiple partitions + $table = new Table('partitioned_archive', ['id' => false, 'primary_key' => ['id']], $this->adapter); + $table->addColumn('id', 'biginteger') + ->addColumn('data', 'text') + ->partitionBy(Partition::TYPE_RANGE, 'id') + ->addPartition('p0', 1000000) + ->addPartition('p1', 2000000) + ->addPartition('p2', 3000000) + ->addPartition('p3', 4000000) + ->addPartition('pmax', 'MAXVALUE') + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_archive')); + + // Insert data into partitions p0 and p1 + $this->adapter->execute( + "INSERT INTO partitioned_archive (id, data) VALUES (500, 'data in p0')", + ); + $this->adapter->execute( + "INSERT INTO partitioned_archive (id, data) VALUES (1500000, 'data in p1')", + ); + $this->adapter->execute( + "INSERT INTO partitioned_archive (id, data) VALUES (2500000, 'data in p2')", + ); + + // Drop multiple partitions at once + // MySQL allows: DROP PARTITION p0, p1 + $table = new Table('partitioned_archive', [], $this->adapter); + $table->dropPartition('p0') + ->dropPartition('p1') + ->save(); + + // Verify the data was removed with the partitions + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_archive WHERE id < 2000000'); + $this->assertCount(0, $rows); + + // Verify data in p2 still exists + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_archive WHERE id = 2500000'); + $this->assertCount(1, $rows); + } + + public function testAddMultipleListPartitionsToExistingTable() + { + // Create a LIST partitioned table + $table = new Table('partitioned_regions', ['id' => false, 'primary_key' => ['id', 'region_id']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('region_id', 'integer') + ->addColumn('name', 'string', ['limit' => 100]) + ->partitionBy(Partition::TYPE_LIST, 'region_id') + ->addPartition('p_north', [1, 2, 3]) + ->addPartition('p_south', [4, 5, 6]) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_regions')); + + // Add multiple LIST partitions at once + $table = new Table('partitioned_regions', [], $this->adapter); + $table->addPartitionToExisting('p_east', [7, 8, 9]) + ->addPartitionToExisting('p_west', [10, 11, 12]) + ->save(); + + // Verify all partitions work by inserting data + $this->adapter->execute( + "INSERT INTO partitioned_regions (id, region_id, name) VALUES (1, 7, 'East Region')", + ); + $this->adapter->execute( + "INSERT INTO partitioned_regions (id, region_id, name) VALUES (2, 10, 'West Region')", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_regions WHERE region_id IN (7, 10)'); + $this->assertCount(2, $rows); + } + + public function testAddPartitionsWithMaxvalue() + { + // Create a partitioned table without MAXVALUE partition + $table = new Table('partitioned_data', ['id' => false, 'primary_key' => ['id']], $this->adapter); + $table->addColumn('id', 'biginteger') + ->addColumn('value', 'integer') + ->partitionBy(Partition::TYPE_RANGE, 'id') + ->addPartition('p0', 100) + ->addPartition('p1', 200) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_data')); + + // Add multiple partitions including one with MAXVALUE + $table = new Table('partitioned_data', [], $this->adapter); + $table->addPartitionToExisting('p2', 300) + ->addPartitionToExisting('pmax', 'MAXVALUE') + ->save(); + + // Verify MAXVALUE partition catches all higher values + $this->adapter->execute( + 'INSERT INTO partitioned_data (id, value) VALUES (250, 1)', + ); + $this->adapter->execute( + 'INSERT INTO partitioned_data (id, value) VALUES (999999, 2)', + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_data WHERE id >= 200'); + $this->assertCount(2, $rows); + } + + public function testCreateTableWithCompositePartitionKey(): void + { + // Test composite partition keys - partitioning by multiple columns + // MySQL RANGE COLUMNS supports multiple columns + $table = new Table('composite_partitioned', ['id' => false, 'primary_key' => ['id', 'year', 'month']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('year', 'integer') + ->addColumn('month', 'integer') + ->addColumn('data', 'string', ['limit' => 100]) + ->partitionBy(Partition::TYPE_RANGE_COLUMNS, ['year', 'month']) + ->addPartition('p202401', [2024, 2]) + ->addPartition('p202402', [2024, 3]) + ->addPartition('p202403', [2024, 4]) + ->create(); + + $this->assertTrue($this->adapter->hasTable('composite_partitioned')); + + // Verify partitioning works by inserting data into different partitions + $this->adapter->execute( + "INSERT INTO composite_partitioned (id, year, month, data) VALUES (1, 2024, 1, 'January')", + ); + $this->adapter->execute( + "INSERT INTO composite_partitioned (id, year, month, data) VALUES (2, 2024, 2, 'February')", + ); + $this->adapter->execute( + "INSERT INTO composite_partitioned (id, year, month, data) VALUES (3, 2024, 3, 'March')", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM composite_partitioned ORDER BY month'); + $this->assertCount(3, $rows); + $this->assertEquals('January', $rows[0]['data']); + $this->assertEquals('February', $rows[1]['data']); + $this->assertEquals('March', $rows[2]['data']); + } + + public function testAddPartitioningToExistingTable(): void + { + // Create a non-partitioned table + $table = new Table('orders', ['id' => false, 'primary_key' => ['id', 'created_at']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('created_at', 'datetime') + ->addColumn('amount', 'decimal', ['precision' => 10, 'scale' => 2]) + ->create(); + + $this->assertTrue($this->adapter->hasTable('orders')); + + // Add partitioning to the existing table + $table = new Table('orders', ['id' => false, 'primary_key' => ['id', 'created_at']], $this->adapter); + $table->partitionBy(Partition::TYPE_RANGE_COLUMNS, 'created_at') + ->addPartition('p2023', '2024-01-01') + ->addPartition('p2024', '2025-01-01') + ->addPartition('pmax', 'MAXVALUE') + ->update(); + + // Verify partitioning was added by inserting data + $this->adapter->execute( + "INSERT INTO orders (id, created_at, amount) VALUES (1, '2023-06-15', 100.00)", + ); + $this->adapter->execute( + "INSERT INTO orders (id, created_at, amount) VALUES (2, '2024-06-15', 200.00)", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM orders'); + $this->assertCount(2, $rows); + + // Verify partitions exist by querying information_schema + $partitions = $this->adapter->fetchAll( + "SELECT PARTITION_NAME FROM information_schema.PARTITIONS + WHERE TABLE_NAME = 'orders' AND TABLE_SCHEMA = DATABASE() AND PARTITION_NAME IS NOT NULL", + ); + $this->assertCount(3, $partitions); + } + + public function testCombinedPartitionAndColumnOperations(): void + { + // Create a partitioned table + $table = new Table('combined_test', ['id' => false, 'primary_key' => ['id', 'created_year']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('created_year', 'integer') + ->addColumn('name', 'string', ['limit' => 100]) + ->partitionBy(Partition::TYPE_RANGE, 'created_year') + ->addPartition('p2022', 2023) + ->addPartition('p2023', 2024) + ->create(); + + $this->assertTrue($this->adapter->hasTable('combined_test')); + + // Combine adding a column AND adding a partition in one save() + $table = new Table('combined_test', [], $this->adapter); + $table->addColumn('description', 'text', ['null' => true]) + ->addPartitionToExisting('p2024', 2025) + ->save(); + + // Verify the column was added + $this->assertTrue($this->adapter->hasColumn('combined_test', 'description')); + + // Verify the partition was added by inserting data + $this->adapter->execute( + "INSERT INTO combined_test (id, created_year, name, description) VALUES (1, 2024, 'Test', 'A description')", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM combined_test WHERE created_year = 2024'); + $this->assertCount(1, $rows); + $this->assertEquals('A description', $rows[0]['description']); } } diff --git a/tests/TestCase/Db/Adapter/PhinxAdapterTest.php b/tests/TestCase/Db/Adapter/PhinxAdapterTest.php deleted file mode 100644 index e8726495a..000000000 --- a/tests/TestCase/Db/Adapter/PhinxAdapterTest.php +++ /dev/null @@ -1,1697 +0,0 @@ - $config */ - $config = ConnectionManager::getConfig('test'); - if ($config['scheme'] !== 'sqlite') { - $this->markTestSkipped('phinx adapter tests require sqlite'); - } - // Emulate the results of Util::parseDsn() - $this->config = [ - 'adapter' => 'sqlite', - 'connection' => ConnectionManager::get('test'), - 'database' => $config['database'], - 'suffix' => '', - ]; - $this->adapter = new PhinxAdapter( - new SqliteAdapter( - $this->config, - $this->getConsoleIo(), - ), - ); - - if ($config['database'] !== ':memory:') { - // ensure the database is empty for each test - $this->adapter->dropDatabase($config['database']); - $this->adapter->createDatabase($config['database']); - } - - // leave the adapter in a disconnected state for each test - $this->adapter->disconnect(); - } - - protected function tearDown(): void - { - unset($this->adapter, $this->out, $this->io); - } - - protected function getConsoleIo(): ConsoleIo - { - $out = new StubConsoleOutput(); - $in = new StubConsoleInput([]); - $io = new ConsoleIo($out, $out, $in); - - $this->out = $out; - $this->io = $io; - - return $this->io; - } - - public function testBeginTransaction() - { - $this->adapter->beginTransaction(); - - $this->assertTrue( - $this->adapter->getConnection()->inTransaction(), - 'Underlying PDO instance did not detect new transaction', - ); - $this->adapter->rollbackTransaction(); - } - - public function testRollbackTransaction() - { - $this->adapter->beginTransaction(); - $this->adapter->rollbackTransaction(); - - $this->assertFalse( - $this->adapter->getConnection()->inTransaction(), - 'Underlying PDO instance did not detect rolled back transaction', - ); - } - - public function testQuoteTableName() - { - $this->assertEquals('"test_table"', $this->adapter->quoteTableName('test_table')); - } - - public function testQuoteColumnName() - { - $this->assertEquals('"test_column"', $this->adapter->quoteColumnName('test_column')); - } - - public function testCreateTable() - { - $table = new PhinxTable('ntable', [], $this->adapter); - $table->addColumn('realname', 'string') - ->addColumn('email', 'integer') - ->save(); - $this->assertTrue($this->adapter->hasTable('ntable')); - $this->assertTrue($this->adapter->hasColumn('ntable', 'id')); - $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); - $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); - $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); - } - - public function testCreateTableCustomIdColumn() - { - $table = new PhinxTable('ntable', ['id' => 'custom_id'], $this->adapter); - $table->addColumn('realname', 'string') - ->addColumn('email', 'integer') - ->save(); - - $this->assertTrue($this->adapter->hasTable('ntable')); - $this->assertTrue($this->adapter->hasColumn('ntable', 'custom_id')); - $this->assertTrue($this->adapter->hasColumn('ntable', 'realname')); - $this->assertTrue($this->adapter->hasColumn('ntable', 'email')); - $this->assertFalse($this->adapter->hasColumn('ntable', 'address')); - - //ensure the primary key is not nullable - /** @var \Phinx\Db\Table\Column $idColumn */ - $idColumn = $this->adapter->getColumns('ntable')[0]; - $this->assertInstanceOf(PhinxColumn::class, $idColumn); - $this->assertTrue($idColumn->getIdentity()); - $this->assertFalse($idColumn->isNull()); - } - - public function testCreateTableIdentityIdColumn() - { - $table = new PhinxTable('ntable', ['id' => false, 'primary_key' => ['custom_id']], $this->adapter); - $table->addColumn('custom_id', 'integer', ['identity' => true]) - ->save(); - - $this->assertTrue($this->adapter->hasTable('ntable')); - $this->assertTrue($this->adapter->hasColumn('ntable', 'custom_id')); - - /** @var \Phinx\Db\Table\Column $idColumn */ - $idColumn = $this->adapter->getColumns('ntable')[0]; - $this->assertInstanceOf(PhinxColumn::class, $idColumn); - $this->assertTrue($idColumn->getIdentity()); - } - - public function testCreateTableWithNoPrimaryKey() - { - $options = [ - 'id' => false, - ]; - $table = new PhinxTable('atable', $options, $this->adapter); - $table->addColumn('user_id', 'integer') - ->save(); - $this->assertFalse($this->adapter->hasColumn('atable', 'id')); - } - - public function testCreateTableWithMultiplePrimaryKeys() - { - $options = [ - 'id' => false, - 'primary_key' => ['user_id', 'tag_id'], - ]; - $table = new PhinxTable('table1', $options, $this->adapter); - $table->addColumn('user_id', 'integer') - ->addColumn('tag_id', 'integer') - ->save(); - $this->assertTrue($this->adapter->hasIndex('table1', ['user_id', 'tag_id'])); - $this->assertTrue($this->adapter->hasIndex('table1', ['USER_ID', 'tag_id'])); - $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'USER_ID'])); - $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_email'])); - } - - /** - * @return void - */ - public function testCreateTableWithPrimaryKeyAsUuid() - { - $options = [ - 'id' => false, - 'primary_key' => 'id', - ]; - $table = new PhinxTable('ztable', $options, $this->adapter); - $table->addColumn('id', 'uuid')->save(); - $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); - $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); - } - - /** - * @return void - */ - public function testCreateTableWithPrimaryKeyAsBinaryUuid() - { - $options = [ - 'id' => false, - 'primary_key' => 'id', - ]; - $table = new PhinxTable('ztable', $options, $this->adapter); - $table->addColumn('id', 'binaryuuid')->save(); - $this->assertTrue($this->adapter->hasColumn('ztable', 'id')); - $this->assertTrue($this->adapter->hasIndex('ztable', 'id')); - } - - public function testCreateTableWithMultipleIndexes() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('email', 'string') - ->addColumn('name', 'string') - ->addIndex('email') - ->addIndex('name') - ->save(); - $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); - $this->assertTrue($this->adapter->hasIndex('table1', ['name'])); - $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); - $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_name'])); - } - - public function testCreateTableWithUniqueIndexes() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('email', 'string') - ->addIndex('email', ['unique' => true]) - ->save(); - $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); - $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); - } - - public function testCreateTableWithNamedIndexes() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('email', 'string') - ->addIndex('email', ['name' => 'myemailindex']) - ->save(); - $this->assertTrue($this->adapter->hasIndex('table1', ['email'])); - $this->assertFalse($this->adapter->hasIndex('table1', ['email', 'user_email'])); - $this->assertTrue($this->adapter->hasIndexByName('table1', 'myemailindex')); - } - - public function testCreateTableWithForeignKey() - { - $refTable = new PhinxTable('ref_table', [], $this->adapter); - $refTable->addColumn('field1', 'string')->save(); - - $table = new PhinxTable('table', [], $this->adapter); - $table->addColumn('ref_table_id', 'integer'); - $table->addForeignKey('ref_table_id', 'ref_table', 'id'); - $table->save(); - - $this->assertTrue($this->adapter->hasTable($table->getName())); - $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); - } - - public function testCreateTableWithIndexesAndForeignKey() - { - $refTable = new PhinxTable('tbl_master', [], $this->adapter); - $refTable->create(); - - $table = new PhinxTable('tbl_child', [], $this->adapter); - $table - ->addColumn('column1', 'integer') - ->addColumn('column2', 'integer') - ->addColumn('master_id', 'integer') - ->addIndex(['column2']) - ->addIndex(['column1', 'column2'], ['unique' => true, 'name' => 'uq_tbl_child_column1_column2_ndx']) - ->addForeignKey( - 'master_id', - 'tbl_master', - 'id', - ['delete' => 'NO_ACTION', 'update' => 'NO_ACTION', 'constraint' => 'fk_master_id'], - ) - ->create(); - - $this->assertTrue($this->adapter->hasIndex('tbl_child', 'column2')); - $this->assertTrue($this->adapter->hasIndex('tbl_child', ['column1', 'column2'])); - $this->assertTrue($this->adapter->hasForeignKey('tbl_child', ['master_id'])); - - $row = $this->adapter->fetchRow( - "SELECT * FROM sqlite_master WHERE \"type\" = 'table' AND \"tbl_name\" = 'tbl_child'", - ); - $this->assertStringContainsString( - 'CONSTRAINT "fk_master_id" FOREIGN KEY ("master_id") REFERENCES "tbl_master" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION', - $row['sql'], - ); - } - - public function testCreateTableWithoutAutoIncrementingPrimaryKeyAndWithForeignKey() - { - $refTable = (new PhinxTable('tbl_master', ['id' => false, 'primary_key' => 'id'], $this->adapter)) - ->addColumn('id', 'text'); - $refTable->create(); - - $table = (new PhinxTable('tbl_child', ['id' => false, 'primary_key' => 'master_id'], $this->adapter)) - ->addColumn('master_id', 'text') - ->addForeignKey( - 'master_id', - 'tbl_master', - 'id', - ['delete' => 'NO_ACTION', 'update' => 'NO_ACTION', 'constraint' => 'fk_master_id'], - ); - $table->create(); - - $this->assertTrue($this->adapter->hasForeignKey('tbl_child', ['master_id'])); - - $row = $this->adapter->fetchRow( - "SELECT * FROM sqlite_master WHERE \"type\" = 'table' AND \"tbl_name\" = 'tbl_child'", - ); - $this->assertStringContainsString( - 'CONSTRAINT "fk_master_id" FOREIGN KEY ("master_id") REFERENCES "tbl_master" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION', - $row['sql'], - ); - } - - public function testAddPrimaryKey() - { - $table = new PhinxTable('table1', ['id' => false], $this->adapter); - $table - ->addColumn('column1', 'integer') - ->addColumn('column2', 'integer') - ->save(); - - $table - ->changePrimaryKey('column1') - ->save(); - - $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column1'])); - } - - public function testChangePrimaryKey() - { - $table = new PhinxTable('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); - $table - ->addColumn('column1', 'integer') - ->addColumn('column2', 'integer') - ->save(); - - $table - ->changePrimaryKey('column2') - ->save(); - - $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); - $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column2'])); - } - - public function testChangePrimaryKeyNonInteger() - { - $table = new PhinxTable('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); - $table - ->addColumn('column1', 'string') - ->addColumn('column2', 'string') - ->save(); - - $table - ->changePrimaryKey('column2') - ->save(); - - $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); - $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column2'])); - } - - public function testDropPrimaryKey() - { - $table = new PhinxTable('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); - $table - ->addColumn('column1', 'integer') - ->addColumn('column2', 'integer') - ->save(); - - $table - ->changePrimaryKey(null) - ->save(); - - $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['column1'])); - } - - public function testAddMultipleColumnPrimaryKeyFails() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table - ->addColumn('column1', 'integer') - ->addColumn('column2', 'integer') - ->save(); - - $this->expectException(InvalidArgumentException::class); - - $table - ->changePrimaryKey(['column1', 'column2']) - ->save(); - } - - public function testChangeCommentFails() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->save(); - - $this->expectException(BadMethodCallException::class); - - $table - ->changeComment('comment1') - ->save(); - } - - public function testAddColumn() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->save(); - $this->assertFalse($table->hasColumn('email')); - $table->addColumn('email', 'string', ['null' => true]) - ->save(); - $this->assertTrue($table->hasColumn('email')); - - // In SQLite it is not possible to dictate order of added columns. - // $table->addColumn('realname', 'string', array('after' => 'id')) - // ->save(); - // $this->assertEquals('realname', $rows[1]['Field']); - } - - public function testAddColumnWithDefaultValue() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->save(); - $table->addColumn('default_zero', 'string', ['default' => 'test']) - ->save(); - $rows = $this->adapter->fetchAll(sprintf('pragma table_info(%s)', 'table1')); - $this->assertEquals("'test'", $rows[1]['dflt_value']); - } - - public function testAddColumnWithDefaultZero() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->save(); - $table->addColumn('default_zero', 'integer', ['default' => 0]) - ->save(); - $rows = $this->adapter->fetchAll(sprintf('pragma table_info(%s)', 'table1')); - $this->assertNotNull($rows[1]['dflt_value']); - $this->assertEquals('0', $rows[1]['dflt_value']); - } - - public function testAddColumnWithDefaultEmptyString() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->save(); - $table->addColumn('default_empty', 'string', ['default' => '']) - ->save(); - $rows = $this->adapter->fetchAll(sprintf('pragma table_info(%s)', 'table1')); - $this->assertEquals("''", $rows[1]['dflt_value']); - } - - public function testRenameColumnWithIndex() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('indexcol', 'integer') - ->addIndex('indexcol') - ->create(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); - $this->assertFalse($this->adapter->hasIndex($table->getName(), 'newindexcol')); - - $table->renameColumn('indexcol', 'newindexcol')->update(); - - $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'newindexcol')); - } - - public function testRenameColumnWithUniqueIndex() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('indexcol', 'integer') - ->addIndex('indexcol', ['unique' => true]) - ->create(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); - $this->assertFalse($this->adapter->hasIndex($table->getName(), 'newindexcol')); - - $table->renameColumn('indexcol', 'newindexcol')->update(); - - $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'newindexcol')); - } - - public function testRenameColumnWithCompositeIndex() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('indexcol1', 'integer') - ->addColumn('indexcol2', 'integer') - ->addIndex(['indexcol1', 'indexcol2']) - ->create(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2'])); - $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'newindexcol2'])); - - $table->renameColumn('indexcol2', 'newindexcol2')->update(); - - $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2'])); - $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcol1', 'newindexcol2'])); - } - - /** - * Tests that rewriting the index SQL does not accidentally change - * the table name in case it matches the column name. - */ - public function testRenameColumnWithIndexMatchingTheTableName() - { - $table = new PhinxTable('indexcol', [], $this->adapter); - $table - ->addColumn('indexcol', 'integer') - ->addIndex('indexcol') - ->create(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); - $this->assertFalse($this->adapter->hasIndex($table->getName(), 'newindexcol')); - - $table->renameColumn('indexcol', 'newindexcol')->update(); - - $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'newindexcol')); - } - - /** - * Tests that rewriting the index SQL does not accidentally change - * column names that partially match the column to rename. - */ - public function testRenameColumnWithIndexColumnPartialMatch() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('indexcol', 'integer') - ->addColumn('indexcolumn', 'integer') - ->create(); - - $this->adapter->execute('CREATE INDEX custom_idx ON t (indexcolumn, indexcol)'); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcolumn', 'indexcol'])); - $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcolumn', 'newindexcol'])); - - $table->renameColumn('indexcol', 'newindexcol')->update(); - - $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcolumn', 'indexcol'])); - $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcolumn', 'newindexcol'])); - } - - public function testRenameColumnWithIndexColumnRequiringQuoting() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('indexcol', 'integer') - ->addIndex('indexcol') - ->create(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); - $this->assertFalse($this->adapter->hasIndex($table->getName(), 'new index col')); - - $table->renameColumn('indexcol', 'new index col')->update(); - - $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'new index col')); - } - - /** - * Indices that are using expressions are not being updated. - */ - public function testRenameColumnWithExpressionIndex() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('indexcol', 'integer') - ->create(); - - $this->adapter->execute('CREATE INDEX custom_idx ON t (`indexcol`, ABS(`indexcol`))'); - - $this->assertTrue($this->adapter->hasIndexByName('t', 'custom_idx')); - - $this->expectException(PDOException::class); - $this->expectExceptionMessage('no such column: indexcol'); - - $table->renameColumn('indexcol', 'newindexcol')->update(); - } - - public function testChangeColumn() - { - $table = new PhinxTable('t', [], $this->adapter); - $table->addColumn('column1', 'string') - ->save(); - $this->assertTrue($this->adapter->hasColumn('t', 'column1')); - $newColumn1 = new PhinxColumn(); - $newColumn1->setName('column1'); - $newColumn1->setType('string'); - $table->changeColumn('column1', $newColumn1); - $this->assertTrue($this->adapter->hasColumn('t', 'column1')); - $newColumn2 = new PhinxColumn(); - $newColumn2->setName('column2') - ->setType('string'); - $table->changeColumn('column1', $newColumn2)->save(); - $this->assertFalse($this->adapter->hasColumn('t', 'column1')); - $this->assertTrue($this->adapter->hasColumn('t', 'column2')); - } - - public function testChangeColumnDefaultValue() - { - $table = new PhinxTable('t', [], $this->adapter); - $table->addColumn('column1', 'string', ['default' => 'test']) - ->save(); - $newColumn1 = new PhinxColumn(); - $newColumn1 - ->setName('column1') - ->setDefault('test1') - ->setType('string'); - $table->changeColumn('column1', $newColumn1)->save(); - $rows = $this->adapter->fetchAll('pragma table_info(t)'); - - $this->assertEquals("'test1'", $rows[1]['dflt_value']); - } - - public function testChangeColumnWithForeignKey() - { - $refTable = new PhinxTable('ref_table', [], $this->adapter); - $refTable->addColumn('field1', 'string')->save(); - - $table = new PhinxTable('another_table', [], $this->adapter); - $table - ->addColumn('ref_table_id', 'integer') - ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) - ->save(); - - $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); - - $table->changeColumn('ref_table_id', 'float')->save(); - - $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); - } - - public function testChangeColumnWithIndex() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('indexcol', 'integer') - ->addIndex( - 'indexcol', - ['unique' => true], - ) - ->create(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); - - $table->changeColumn('indexcol', 'integer', ['null' => false])->update(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); - } - - public function testChangeColumnWithTrigger() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('triggercol', 'integer') - ->addColumn('othercol', 'integer') - ->create(); - - $triggerSQL = - 'CREATE TRIGGER update_t_othercol UPDATE OF triggercol ON t - BEGIN - UPDATE t SET othercol = new.triggercol; - END'; - - $this->adapter->execute($triggerSQL); - - $rows = $this->adapter->fetchAll( - "SELECT * FROM sqlite_master WHERE `type` = 'trigger' AND tbl_name = 't'", - ); - $this->assertCount(1, $rows); - $this->assertEquals('trigger', $rows[0]['type']); - $this->assertEquals('update_t_othercol', $rows[0]['name']); - $this->assertEquals($triggerSQL, $rows[0]['sql']); - - $table->changeColumn('triggercol', 'integer', ['null' => false])->update(); - - $rows = $this->adapter->fetchAll( - "SELECT * FROM sqlite_master WHERE `type` = 'trigger' AND tbl_name = 't'", - ); - $this->assertCount(1, $rows); - $this->assertEquals('trigger', $rows[0]['type']); - $this->assertEquals('update_t_othercol', $rows[0]['name']); - $this->assertEquals($triggerSQL, $rows[0]['sql']); - } - - public function testChangeColumnDefaultToZero() - { - $table = new PhinxTable('t', [], $this->adapter); - $table->addColumn('column1', 'integer') - ->save(); - $newColumn1 = new PhinxColumn(); - $newColumn1->setDefault(0) - ->setName('column1') - ->setType('integer'); - $table->changeColumn('column1', $newColumn1)->save(); - $rows = $this->adapter->fetchAll('pragma table_info(t)'); - $this->assertEquals('0', $rows[1]['dflt_value']); - } - - public function testChangeColumnDefaultToNull() - { - $table = new PhinxTable('t', [], $this->adapter); - $table->addColumn('column1', 'string', ['default' => 'test']) - ->save(); - $newColumn1 = new PhinxColumn(); - $newColumn1->setDefault(null) - ->setName('column1') - ->setType('string'); - $table->changeColumn('column1', $newColumn1)->save(); - $rows = $this->adapter->fetchAll('pragma table_info(t)'); - $this->assertNull($rows[1]['dflt_value']); - } - - public function testChangeColumnWithCommasInCommentsOrDefaultValue() - { - $table = new PhinxTable('t', [], $this->adapter); - $table->addColumn('column1', 'string', ['default' => 'one, two or three', 'comment' => 'three, two or one']) - ->save(); - $newColumn1 = new PhinxColumn(); - $newColumn1->setDefault('another default') - ->setName('column1') - ->setComment('another comment') - ->setType('string'); - $table->changeColumn('column1', $newColumn1)->save(); - $cols = $this->adapter->getColumns('t'); - $this->assertEquals('another default', (string)$cols[1]->getDefault()); - } - - public function testDropColumnWithIndex() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('indexcol', 'integer') - ->addIndex('indexcol') - ->create(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); - - $table->removeColumn('indexcol')->update(); - - $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); - } - - public function testDropColumnWithUniqueIndex() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('indexcol', 'integer') - ->addIndex('indexcol', ['unique' => true]) - ->create(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcol')); - - $table->removeColumn('indexcol')->update(); - - $this->assertFalse($this->adapter->hasIndex($table->getName(), 'indexcol')); - } - - public function testDropColumnWithCompositeIndex() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('indexcol1', 'integer') - ->addColumn('indexcol2', 'integer') - ->addIndex(['indexcol1', 'indexcol2']) - ->create(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2'])); - - $table->removeColumn('indexcol2')->update(); - - $this->assertFalse($this->adapter->hasIndex($table->getName(), ['indexcol1', 'indexcol2'])); - } - - /** - * Tests that removing columns does not accidentally drop indices - * on table names that match the column to remove. - */ - public function testDropColumnWithIndexMatchingTheTableName() - { - $table = new PhinxTable('indexcol', [], $this->adapter); - $table - ->addColumn('indexcol', 'integer') - ->addColumn('indexcolumn', 'integer') - ->addIndex('indexcolumn') - ->create(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcolumn')); - - $table->removeColumn('indexcol')->update(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcolumn')); - } - - /** - * Tests that removing columns does not accidentally drop indices - * that contain column names that partially match the column to remove. - */ - public function testDropColumnWithIndexColumnPartialMatch() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('indexcol', 'integer') - ->addColumn('indexcolumn', 'integer') - ->create(); - - $this->adapter->execute('CREATE INDEX custom_idx ON t (indexcolumn)'); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcolumn')); - - $table->removeColumn('indexcol')->update(); - - $this->assertTrue($this->adapter->hasIndex($table->getName(), 'indexcolumn')); - } - - /** - * Indices with expressions are not being removed. - */ - public function testDropColumnWithExpressionIndex() - { - $table = new PhinxTable('t', [], $this->adapter); - $table - ->addColumn('indexcol', 'integer') - ->create(); - - $this->adapter->execute('CREATE INDEX custom_idx ON t (ABS(indexcol))'); - - $this->assertTrue($this->adapter->hasIndexByName('t', 'custom_idx')); - - $this->expectException(PDOException::class); - $this->expectExceptionMessage('no such column: indexcol'); - - $table->removeColumn('indexcol')->update(); - } - - public static function columnsProvider() - { - return [ - ['column1', 'string', []], - ['column2', 'integer', []], - ['column3', 'biginteger', []], - ['column4', 'text', []], - ['column5', 'float', []], - ['column7', 'datetime', []], - ['column8', 'time', []], - ['column9', 'timestamp', []], - ['column10', 'date', []], - ['column11', 'binary', []], - ['column13', 'string', ['limit' => 10]], - ['column15', 'smallinteger', []], - ['column15', 'integer', []], - ['column23', 'json', []], - ]; - } - - public function testAddIndex() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('email', 'string') - ->save(); - $this->assertFalse($table->hasIndex('email')); - $table->addIndex('email') - ->save(); - $this->assertTrue($table->hasIndex('email')); - } - - public function testAddForeignKey() - { - $refTable = new PhinxTable('ref_table', [], $this->adapter); - $refTable->addColumn('field1', 'string')->save(); - - $table = new PhinxTable('table', [], $this->adapter); - $table - ->addColumn('ref_table_id', 'integer') - ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) - ->save(); - - $this->assertTrue($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); - } - - public function testHasDatabase() - { - if ($this->config['database'] === ':memory:') { - $this->markTestSkipped('Skipping hasDatabase() when testing in-memory db.'); - } - $this->assertFalse($this->adapter->hasDatabase('fake_database_name')); - $this->assertTrue($this->adapter->hasDatabase($this->config['database'])); - } - - public function testDropDatabase() - { - $this->assertFalse($this->adapter->hasDatabase('phinx_temp_database')); - $this->adapter->createDatabase('phinx_temp_database'); - $this->assertTrue($this->adapter->hasDatabase('phinx_temp_database')); - $this->adapter->dropDatabase('phinx_temp_database'); - } - - public function testAddColumnWithComment() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('column1', 'string', ['comment' => $comment = 'Comments from "column1"']) - ->save(); - - $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); - - foreach ($rows as $row) { - if ($row['tbl_name'] === 'table1') { - $sql = $row['sql']; - } - } - - $this->assertMatchesRegularExpression('/\/\* Comments from "column1" \*\//', $sql); - } - - public function testAddIndexTwoTablesSameIndex() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('email', 'string') - ->save(); - $table2 = new PhinxTable('table2', [], $this->adapter); - $table2->addColumn('email', 'string') - ->save(); - - $this->assertFalse($table->hasIndex('email')); - $this->assertFalse($table2->hasIndex('email')); - - $table->addIndex('email') - ->save(); - $table2->addIndex('email') - ->save(); - - $this->assertTrue($table->hasIndex('email')); - $this->assertTrue($table2->hasIndex('email')); - } - - public function testBulkInsertData() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('column1', 'string') - ->addColumn('column2', 'integer', ['null' => true]) - ->insert([ - [ - 'column1' => 'value1', - 'column2' => 1, - ], - [ - 'column1' => 'value2', - 'column2' => 2, - ], - ]) - ->insert( - [ - 'column1' => 'value3', - 'column2' => 3, - ], - ) - ->insert( - [ - 'column1' => '\'value4\'', - 'column2' => null, - ], - ) - ->save(); - $rows = $this->adapter->fetchAll('SELECT * FROM table1'); - - $this->assertEquals('value1', $rows[0]['column1']); - $this->assertEquals('value2', $rows[1]['column1']); - $this->assertEquals('value3', $rows[2]['column1']); - $this->assertEquals('\'value4\'', $rows[3]['column1']); - $this->assertEquals(1, $rows[0]['column2']); - $this->assertEquals(2, $rows[1]['column2']); - $this->assertEquals(3, $rows[2]['column2']); - $this->assertNull($rows[3]['column2']); - } - - public function testInsertData() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('column1', 'string') - ->addColumn('column2', 'integer', ['null' => true]) - ->insert([ - [ - 'column1' => 'value1', - 'column2' => 1, - ], - [ - 'column1' => 'value2', - 'column2' => 2, - ], - ]) - ->insert( - [ - 'column1' => 'value3', - 'column2' => 3, - ], - ) - ->insert( - [ - 'column1' => '\'value4\'', - 'column2' => null, - ], - ) - ->save(); - - $rows = $this->adapter->fetchAll('SELECT * FROM table1'); - - $this->assertEquals('value1', $rows[0]['column1']); - $this->assertEquals('value2', $rows[1]['column1']); - $this->assertEquals('value3', $rows[2]['column1']); - $this->assertEquals('\'value4\'', $rows[3]['column1']); - $this->assertEquals(1, $rows[0]['column2']); - $this->assertEquals(2, $rows[1]['column2']); - $this->assertEquals(3, $rows[2]['column2']); - $this->assertNull($rows[3]['column2']); - } - - public function testBulkInsertDataEnum() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('column1', 'string') - ->addColumn('column2', 'string', ['null' => true]) - ->addColumn('column3', 'string', ['default' => 'c']) - ->insert([ - 'column1' => 'a', - ]) - ->save(); - - $rows = $this->adapter->fetchAll('SELECT * FROM table1'); - - $this->assertEquals('a', $rows[0]['column1']); - $this->assertNull($rows[0]['column2']); - $this->assertEquals('c', $rows[0]['column3']); - } - - public function testNullWithoutDefaultValue() - { - $this->markTestSkipped('Skipping for now. See Github Issue #265.'); - - // construct table with default/null combinations - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('aa', 'string', ['null' => true]) // no default value - ->addColumn('bb', 'string', ['null' => false]) // no default value - ->addColumn('cc', 'string', ['null' => true, 'default' => 'some1']) - ->addColumn('dd', 'string', ['null' => false, 'default' => 'some2']) - ->save(); - - // load table info - $columns = $this->adapter->getColumns('table1'); - - $this->assertCount(5, $columns); - - $aa = $columns[1]; - $bb = $columns[2]; - $cc = $columns[3]; - $dd = $columns[4]; - - $this->assertEquals('aa', $aa->getName()); - $this->assertTrue($aa->isNull()); - $this->assertNull($aa->getDefault()); - - $this->assertEquals('bb', $bb->getName()); - $this->assertFalse($bb->isNull()); - $this->assertNull($bb->getDefault()); - - $this->assertEquals('cc', $cc->getName()); - $this->assertTrue($cc->isNull()); - $this->assertEquals('some1', $cc->getDefault()); - - $this->assertEquals('dd', $dd->getName()); - $this->assertFalse($dd->isNull()); - $this->assertEquals('some2', $dd->getDefault()); - } - - public function testDumpCreateTable() - { - $this->adapter->setOptions(['dryrun' => true]); - - $table = new PhinxTable('table1', [], $this->adapter); - - $table->addColumn('column1', 'string', ['null' => false]) - ->addColumn('column2', 'integer') - ->addColumn('column3', 'string', ['default' => 'test']) - ->save(); - - $expectedOutput = <<<'OUTPUT' -CREATE TABLE "table1" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "column1" VARCHAR NOT NULL, "column2" INTEGER, "column3" VARCHAR DEFAULT 'test'); -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'); - } - - /** - * Creates the table "table1". - * Then sets phinx to dry run mode and inserts a record. - * Asserts that phinx outputs the insert statement and doesn't insert a record. - */ - public function testDumpInsert() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('string_col', 'string') - ->addColumn('int_col', 'integer') - ->save(); - - $this->adapter->setOptions(['dryrun' => true]); - $this->adapter->insert($table->getTable(), [ - 'string_col' => 'test data', - ]); - - $this->adapter->insert($table->getTable(), [ - 'string_col' => null, - ]); - - $this->adapter->insert($table->getTable(), [ - 'int_col' => 23, - ]); - - $expectedOutput = <<<'OUTPUT' -INSERT INTO "table1" ("string_col") VALUES ('test data'); -INSERT INTO "table1" ("string_col") VALUES (null); -INSERT INTO "table1" ("int_col") VALUES (23); -OUTPUT; - $actualOutput = join("\n", $this->out->messages()); - $actualOutput = preg_replace("/\r\n|\r/", "\n", $actualOutput); // normalize line endings for Windows - $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option doesn\'t dump the insert to the output'); - - $countQuery = $this->adapter->query('SELECT COUNT(*) FROM table1'); - $this->assertTrue($countQuery->execute()); - $res = $countQuery->fetchAll('assoc'); - $this->assertEquals(0, $res[0]['COUNT(*)']); - } - - /** - * Creates the table "table1". - * Then sets phinx to dry run mode and inserts some records. - * Asserts that phinx outputs the insert statement and doesn't insert any record. - */ - public function testDumpBulkinsert() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('string_col', 'string') - ->addColumn('int_col', 'integer') - ->save(); - - $this->adapter->setOptions(['dryrun' => true]); - $this->adapter->bulkinsert($table->getTable(), [ - [ - 'string_col' => 'test_data1', - 'int_col' => 23, - ], - [ - 'string_col' => null, - 'int_col' => 42, - ], - ]); - - $expectedOutput = <<<'OUTPUT' -INSERT INTO "table1" ("string_col", "int_col") VALUES ('test_data1', 23), (null, 42); -OUTPUT; - $actualOutput = join("\n", $this->out->messages()); - $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option doesn\'t dump the bulkinsert to the output'); - - $countQuery = $this->adapter->query('SELECT COUNT(*) FROM table1'); - $this->assertTrue($countQuery->execute()); - $res = $countQuery->fetchAll('assoc'); - $this->assertEquals(0, $res[0]['COUNT(*)']); - } - - public function testDumpCreateTableAndThenInsert() - { - $this->adapter->setOptions(['dryrun' => true]); - - $table = new PhinxTable('table1', ['id' => false, 'primary_key' => ['column1']], $this->adapter); - - $table->addColumn('column1', 'string', ['null' => false]) - ->addColumn('column2', 'integer') - ->save(); - - $expectedOutput = 'C'; - - $table = new PhinxTable('table1', [], $this->adapter); - $table->insert([ - 'column1' => 'id1', - 'column2' => 1, - ])->save(); - - $expectedOutput = <<<'OUTPUT' -CREATE TABLE "table1" ("column1" VARCHAR NOT NULL, "column2" INTEGER, PRIMARY KEY ("column1")); -INSERT INTO "table1" ("column1", "column2") VALUES ('id1', 1); -OUTPUT; - $actualOutput = join("\n", $this->out->messages()); - $actualOutput = preg_replace("/\r\n|\r/", "\n", $actualOutput); // normalize line endings for Windows - $this->assertStringContainsString($expectedOutput, $actualOutput, 'Passing the --dry-run option does not dump create and then insert table queries to the output'); - } - - /** - * Tests interaction with the query builder - */ - public function testQueryBuilder() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('string_col', 'string') - ->addColumn('int_col', 'integer') - ->save(); - - $builder = $this->adapter->getQueryBuilder(Query::TYPE_INSERT); - $stm = $builder - ->insert(['string_col', 'int_col']) - ->into('table1') - ->values(['string_col' => 'value1', 'int_col' => 1]) - ->values(['string_col' => 'value2', 'int_col' => 2]) - ->execute(); - - $this->assertEquals(2, $stm->rowCount()); - - $builder = $this->adapter->getQueryBuilder(Query::TYPE_SELECT); - $stm = $builder - ->select('*') - ->from('table1') - ->where(['int_col >=' => 2]) - ->execute(); - - $this->assertEquals(0, $stm->rowCount()); - $this->assertEquals( - ['id' => 2, 'string_col' => 'value2', 'int_col' => '2'], - $stm->fetch('assoc'), - ); - - $builder = $this->adapter->getQueryBuilder(Query::TYPE_DELETE); - $stm = $builder - ->delete('table1') - ->where(['int_col <' => 2]) - ->execute(); - - $this->assertEquals(1, $stm->rowCount()); - } - - public function testQueryWithParams() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->addColumn('string_col', 'string') - ->addColumn('int_col', 'integer') - ->save(); - - $this->adapter->insert($table->getTable(), [ - 'string_col' => 'test data', - 'int_col' => 10, - ]); - - $this->adapter->insert($table->getTable(), [ - 'string_col' => null, - ]); - - $this->adapter->insert($table->getTable(), [ - 'int_col' => 23, - ]); - - $countQuery = $this->adapter->query('SELECT COUNT(*) AS c FROM table1 WHERE int_col > ?', [5]); - $res = $countQuery->fetchAll('assoc'); - $this->assertEquals(2, $res[0]['c']); - - $this->adapter->execute('UPDATE table1 SET int_col = ? WHERE int_col IS NULL', [12]); - - $countQuery->execute([1]); - $res = $countQuery->fetchAll('assoc'); - $this->assertEquals(3, $res[0]['c']); - } - - /** - * Tests adding more than one column to a table - * that already exists due to adapters having different add column instructions - */ - public function testAlterTableColumnAdd() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->create(); - - $table->addColumn('string_col', 'string', ['default' => '']); - $table->addColumn('string_col_2', 'string', ['null' => true]); - $table->addColumn('string_col_3', 'string', ['null' => false]); - $table->addTimestamps(); - $table->save(); - - $columns = $this->adapter->getColumns('table1'); - $expected = [ - ['name' => 'id', 'type' => 'integer', 'default' => null, 'null' => false], - ['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], - ]; - - $this->assertEquals(count($expected), count($columns)); - - $columnCount = count($columns); - for ($i = 0; $i < $columnCount; $i++) { - $this->assertSame($expected[$i]['name'], $columns[$i]->getName(), "Wrong name for {$expected[$i]['name']}"); - $this->assertSame($expected[$i]['type'], $columns[$i]->getType(), "Wrong type for {$expected[$i]['name']}"); - $this->assertSame($expected[$i]['default'], $columns[$i]->getDefault() instanceof Literal ? (string)$columns[$i]->getDefault() : $columns[$i]->getDefault(), "Wrong default for {$expected[$i]['name']}"); - $this->assertSame($expected[$i]['null'], $columns[$i]->getNull(), "Wrong null for {$expected[$i]['name']}"); - } - } - - public function testAlterTableWithConstraints() - { - $table = new PhinxTable('table1', [], $this->adapter); - $table->create(); - - $table2 = new PhinxTable('table2', [], $this->adapter); - $table2->create(); - - $table - ->addColumn('table2_id', 'integer', ['null' => false]) - ->addForeignKey('table2_id', 'table2', 'id', [ - 'delete' => 'SET NULL', - ]); - $table->update(); - - $table->addColumn('column3', 'string', ['default' => null, 'null' => true]); - $table->update(); - - $columns = $this->adapter->getColumns('table1'); - $expected = [ - ['name' => 'id', 'type' => 'integer', 'default' => null, 'null' => false], - ['name' => 'table2_id', 'type' => 'integer', 'default' => null, 'null' => false], - ['name' => 'column3', 'type' => 'string', 'default' => null, 'null' => true], - ]; - - $this->assertEquals(count($expected), count($columns)); - - $columnCount = count($columns); - for ($i = 0; $i < $columnCount; $i++) { - $this->assertSame($expected[$i]['name'], $columns[$i]->getName(), "Wrong name for {$expected[$i]['name']}"); - $this->assertSame($expected[$i]['type'], $columns[$i]->getType(), "Wrong type for {$expected[$i]['name']}"); - $this->assertSame($expected[$i]['default'], $columns[$i]->getDefault() instanceof Literal ? (string)$columns[$i]->getDefault() : $columns[$i]->getDefault(), "Wrong default for {$expected[$i]['name']}"); - $this->assertSame($expected[$i]['null'], $columns[$i]->getNull(), "Wrong null for {$expected[$i]['name']}"); - } - } - - /** - * Tests that operations that trigger implicit table drops will not cause - * a foreign key constraint violation error. - */ - public function testAlterTableDoesNotViolateRestrictedForeignKeyConstraint() - { - $this->adapter->execute('PRAGMA foreign_keys = ON'); - - $articlesTable = new PhinxTable('articles', [], $this->adapter); - $articlesTable - ->insert(['id' => 1]) - ->save(); - - $commentsTable = new PhinxTable('comments', [], $this->adapter); - $commentsTable - ->addColumn('article_id', 'integer') - ->addForeignKey('article_id', 'articles', 'id', [ - 'update' => ForeignKey::RESTRICT, - 'delete' => ForeignKey::RESTRICT, - ]) - ->insert(['id' => 1, 'article_id' => 1]) - ->save(); - - $this->assertTrue($this->adapter->hasForeignKey('comments', ['article_id'])); - - $articlesTable - ->addColumn('new_column', 'integer') - ->update(); - - $articlesTable - ->renameColumn('new_column', 'new_column_renamed') - ->update(); - - $articlesTable - ->changeColumn('new_column_renamed', 'integer', [ - 'default' => 1, - ]) - ->update(); - - $articlesTable - ->removeColumn('new_column_renamed') - ->update(); - - $articlesTable - ->addIndex('id', ['name' => 'ID_IDX']) - ->update(); - - $articlesTable - ->removeIndex('id') - ->update(); - - $articlesTable - ->addForeignKey('id', 'comments', 'id') - ->update(); - - $articlesTable - ->dropForeignKey('id') - ->update(); - - $articlesTable - ->addColumn('id2', 'integer') - ->addIndex('id', ['unique' => true]) - ->changePrimaryKey('id2') - ->update(); - } - - /** - * Tests that foreign key constraint violations introduced around the table - * alteration process (being it implicitly by the process itself or by the user) - * will trigger an error accordingly. - */ - public function testAlterTableDoesViolateForeignKeyConstraintOnTargetTableChange() - { - $articlesTable = new PhinxTable('articles', [], $this->adapter); - $articlesTable - ->insert(['id' => 1]) - ->save(); - - $commentsTable = new PhinxTable('comments', [], $this->adapter); - $commentsTable - ->addColumn('article_id', 'integer') - ->addForeignKey('article_id', 'articles', 'id', [ - 'update' => ForeignKey::RESTRICT, - 'delete' => ForeignKey::RESTRICT, - ]) - ->insert(['id' => 1, 'article_id' => 1]) - ->save(); - - $this->assertTrue($this->adapter->hasForeignKey('comments', ['article_id'])); - - $this->adapter->execute('PRAGMA foreign_keys = OFF'); - $this->adapter->execute('DELETE FROM articles'); - $this->adapter->execute('PRAGMA foreign_keys = ON'); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Integrity constraint violation: FOREIGN KEY constraint on `comments` failed.'); - - $articlesTable - ->addColumn('new_column', 'integer') - ->update(); - } - - public function testLiteralSupport() - { - $createQuery = <<<'INPUT' -CREATE TABLE `test` (`real_col` DECIMAL) -INPUT; - $this->adapter->execute($createQuery); - $table = new PhinxTable('test', [], $this->adapter); - $columns = $table->getColumns(); - $this->assertCount(1, $columns); - $this->assertEquals(Literal::from('decimal'), array_pop($columns)->getType()); - } - - public function testHasNamedPrimaryKey() - { - $this->expectException(InvalidArgumentException::class); - - $this->adapter->hasPrimaryKey('t', [], 'named_constraint'); - } - - public function testGetColumnTypes() - { - $columnTypes = $this->adapter->getColumnTypes(); - $expected = [ - SqliteAdapter::PHINX_TYPE_BIG_INTEGER, - SqliteAdapter::PHINX_TYPE_BINARY, - SqliteAdapter::PHINX_TYPE_BLOB, - SqliteAdapter::PHINX_TYPE_BOOLEAN, - SqliteAdapter::PHINX_TYPE_CHAR, - SqliteAdapter::PHINX_TYPE_DATE, - SqliteAdapter::PHINX_TYPE_DATETIME, - SqliteAdapter::PHINX_TYPE_DECIMAL, - SqliteAdapter::PHINX_TYPE_DOUBLE, - SqliteAdapter::PHINX_TYPE_FLOAT, - SqliteAdapter::PHINX_TYPE_INTEGER, - SqliteAdapter::PHINX_TYPE_JSON, - SqliteAdapter::PHINX_TYPE_JSONB, - SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, - SqliteAdapter::PHINX_TYPE_STRING, - SqliteAdapter::PHINX_TYPE_TEXT, - SqliteAdapter::PHINX_TYPE_TIME, - SqliteAdapter::PHINX_TYPE_UUID, - SqliteAdapter::PHINX_TYPE_BINARYUUID, - SqliteAdapter::PHINX_TYPE_TIMESTAMP, - SqliteAdapter::PHINX_TYPE_TINY_INTEGER, - SqliteAdapter::PHINX_TYPE_VARBINARY, - ]; - sort($columnTypes); - sort($expected); - - $this->assertEquals($expected, $columnTypes); - } - - #[DataProvider('provideColumnTypesForValidation')] - public function testIsValidColumnType($phinxType, $exp) - { - $col = (new PhinxColumn())->setType($phinxType); - $this->assertSame($exp, $this->adapter->isValidColumnType($col)); - } - - public static function provideColumnTypesForValidation() - { - return [ - [SqliteAdapter::PHINX_TYPE_BIG_INTEGER, true], - [SqliteAdapter::PHINX_TYPE_BINARY, true], - [SqliteAdapter::PHINX_TYPE_BLOB, true], - [SqliteAdapter::PHINX_TYPE_BOOLEAN, true], - [SqliteAdapter::PHINX_TYPE_CHAR, true], - [SqliteAdapter::PHINX_TYPE_DATE, true], - [SqliteAdapter::PHINX_TYPE_DATETIME, true], - [SqliteAdapter::PHINX_TYPE_DOUBLE, true], - [SqliteAdapter::PHINX_TYPE_FLOAT, true], - [SqliteAdapter::PHINX_TYPE_INTEGER, true], - [SqliteAdapter::PHINX_TYPE_JSON, true], - [SqliteAdapter::PHINX_TYPE_JSONB, true], - [SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, true], - [SqliteAdapter::PHINX_TYPE_STRING, true], - [SqliteAdapter::PHINX_TYPE_TEXT, true], - [SqliteAdapter::PHINX_TYPE_TIME, true], - [SqliteAdapter::PHINX_TYPE_UUID, true], - [SqliteAdapter::PHINX_TYPE_TIMESTAMP, true], - [SqliteAdapter::PHINX_TYPE_VARBINARY, true], - [SqliteAdapter::PHINX_TYPE_BIT, false], - [SqliteAdapter::PHINX_TYPE_CIDR, false], - [SqliteAdapter::PHINX_TYPE_DECIMAL, true], - [SqliteAdapter::PHINX_TYPE_ENUM, false], - [SqliteAdapter::PHINX_TYPE_FILESTREAM, false], - [SqliteAdapter::PHINX_TYPE_GEOMETRY, false], - [SqliteAdapter::PHINX_TYPE_INET, false], - [SqliteAdapter::PHINX_TYPE_INTERVAL, false], - [SqliteAdapter::PHINX_TYPE_LINESTRING, false], - [SqliteAdapter::PHINX_TYPE_MACADDR, false], - [SqliteAdapter::PHINX_TYPE_POINT, false], - [SqliteAdapter::PHINX_TYPE_POLYGON, false], - [SqliteAdapter::PHINX_TYPE_SET, false], - [PhinxLiteral::from('someType'), true], - ['someType', false], - ]; - } - - public function testGetColumns() - { - $conn = $this->adapter->getConnection(); - $conn->execute('create table t(a integer, b text, c char(5), d decimal(12,6), e integer not null, f integer null)'); - $exp = [ - ['name' => 'a', 'type' => 'integer', 'null' => true, 'limit' => null, 'precision' => null, 'scale' => null], - ['name' => 'b', 'type' => 'text', 'null' => true, 'limit' => null, 'precision' => null, 'scale' => null], - ['name' => 'c', 'type' => 'char', 'null' => true, 'limit' => 5, 'precision' => 5, 'scale' => null], - ['name' => 'd', 'type' => 'decimal', 'null' => true, 'limit' => 12, 'precision' => 12, 'scale' => 6], - ['name' => 'e', 'type' => 'integer', 'null' => false, 'limit' => null, 'precision' => null, 'scale' => null], - ['name' => 'f', 'type' => 'integer', 'null' => true, 'limit' => null, 'precision' => null, 'scale' => null], - ]; - $act = $this->adapter->getColumns('t'); - $this->assertCount(count($exp), $act); - foreach ($exp as $index => $data) { - $this->assertInstanceOf(PhinxColumn::class, $act[$index]); - foreach ($data as $key => $value) { - $m = 'get' . ucfirst($key); - $this->assertEquals($value, $act[$index]->$m(), "Parameter '$key' of column at index $index did not match expectations."); - } - } - } - - public function testForeignKeyReferenceCorrectAfterRenameColumn() - { - $refTableColumnId = 'ref_table_id'; - $refTableColumnToRename = 'columnToRename'; - $refTableRenamedColumn = 'renamedColumn'; - $refTable = new PhinxTable('ref_table', [], $this->adapter); - $refTable->addColumn($refTableColumnToRename, 'string')->save(); - - $table = new PhinxTable('table', [], $this->adapter); - $table->addColumn($refTableColumnId, 'integer'); - $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); - $table->save(); - - $refTable->renameColumn($refTableColumnToRename, $refTableRenamedColumn)->save(); - - $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); - $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); - $this->assertTrue($this->adapter->hasColumn($refTable->getName(), $refTableRenamedColumn)); - - $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); - foreach ($rows as $row) { - if ($row['tbl_name'] === $table->getName()) { - $sql = $row['sql']; - } - } - $this->assertStringContainsString("REFERENCES \"{$refTable->getName()}\" (\"id\")", $sql); - } - - public function testForeignKeyReferenceCorrectAfterChangeColumn() - { - $refTableColumnId = 'ref_table_id'; - $refTableColumnToChange = 'columnToChange'; - $refTable = new PhinxTable('ref_table', [], $this->adapter); - $refTable->addColumn($refTableColumnToChange, 'string')->save(); - - $table = new PhinxTable('table', [], $this->adapter); - $table->addColumn($refTableColumnId, 'integer'); - $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); - $table->save(); - - $refTable->changeColumn($refTableColumnToChange, 'text')->save(); - - $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); - $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); - $this->assertEquals('text', $this->adapter->getColumns($refTable->getName())[1]->getType()); - - $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); - foreach ($rows as $row) { - if ($row['tbl_name'] === $table->getName()) { - $sql = $row['sql']; - } - } - $this->assertStringContainsString("REFERENCES \"{$refTable->getName()}\" (\"id\")", $sql); - } - - public function testForeignKeyReferenceCorrectAfterRemoveColumn() - { - $refTableColumnId = 'ref_table_id'; - $refTableColumnToRemove = 'columnToRemove'; - $refTable = new PhinxTable('ref_table', [], $this->adapter); - $refTable->addColumn($refTableColumnToRemove, 'string')->save(); - - $table = new PhinxTable('table', [], $this->adapter); - $table->addColumn($refTableColumnId, 'integer'); - $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); - $table->save(); - - $refTable->removeColumn($refTableColumnToRemove)->save(); - - $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); - $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); - $this->assertFalse($this->adapter->hasColumn($refTable->getName(), $refTableColumnToRemove)); - - $rows = $this->adapter->fetchAll('select * from sqlite_master where "type" = \'table\''); - foreach ($rows as $row) { - if ($row['tbl_name'] === $table->getName()) { - $sql = $row['sql']; - } - } - $this->assertStringContainsString("REFERENCES \"{$refTable->getName()}\" (\"id\")", $sql); - } - - public function testForeignKeyReferenceCorrectAfterChangePrimaryKey() - { - $refTableColumnAdditionalId = 'additional_id'; - $refTableColumnId = 'ref_table_id'; - $refTable = new PhinxTable('ref_table', [], $this->adapter); - $refTable->addColumn($refTableColumnAdditionalId, 'integer')->save(); - - $table = new PhinxTable('table', [], $this->adapter); - $table->addColumn($refTableColumnId, 'integer'); - $table->addForeignKey($refTableColumnId, $refTable->getName(), 'id'); - $table->save(); - - $refTable - ->addIndex('id', ['unique' => true]) - ->changePrimaryKey($refTableColumnAdditionalId) - ->save(); - - $this->assertTrue($this->adapter->hasForeignKey($table->getName(), [$refTableColumnId])); - $this->assertFalse($this->adapter->hasTable("tmp_{$refTable->getName()}")); - $this->assertTrue($this->adapter->getColumns($refTable->getName())[1]->getIdentity()); - - $rows = $this->adapter->fetchAll('select * from sqlite_master where `type` = \'table\''); - foreach ($rows as $row) { - if ($row['tbl_name'] === $table->getName()) { - $sql = $row['sql']; - } - } - $this->assertStringContainsString("REFERENCES \"{$refTable->getName()}\" (\"id\")", $sql); - } -} diff --git a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php index b514f3553..58c03871e 100644 --- a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php +++ b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php @@ -11,16 +11,19 @@ use InvalidArgumentException; use Migrations\Db\Adapter\AdapterInterface; use Migrations\Db\Adapter\PostgresAdapter; -use Migrations\Db\Adapter\UnsupportedColumnTypeException; use Migrations\Db\Literal; use Migrations\Db\Table; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; +use Migrations\Db\Table\Partition; use PDO; +use PDOException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\TestCase; +use RuntimeException; class PostgresAdapterTest extends TestCase { @@ -291,7 +294,7 @@ public function testCreateTableWithMultiplePrimaryKeys() ->addColumn('tag_id', 'integer') ->save(); $this->assertTrue($this->adapter->hasIndex('table1', ['user_id', 'tag_id'])); - $this->assertTrue($this->adapter->hasIndex('table1', ['tag_id', 'user_id'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_id'])); $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_email'])); } @@ -308,7 +311,7 @@ public function testCreateTableWithMultiplePrimaryKeysWithSchema() ->addColumn('tag_id', 'integer') ->save(); $this->assertTrue($this->adapter->hasIndex('schema1.table1', ['user_id', 'tag_id'])); - $this->assertTrue($this->adapter->hasIndex('schema1.table1', ['tag_id', 'user_id'])); + $this->assertFalse($this->adapter->hasIndex('schema1.table1', ['tag_id', 'user_id'])); $this->assertFalse($this->adapter->hasIndex('schema1.table1', ['tag_id', 'user_email'])); $this->adapter->dropSchema('schema1'); @@ -638,7 +641,6 @@ public function testAddColumnWithDefaultZero() public function testAddColumnWithAutoIdentity() { - $this->markTestIncomplete('Requires cakephp/database to use identity columns'); if (!$this->usingPostgres10()) { $this->markTestSkipped('Test Skipped because of PostgreSQL version is < 10.0'); } @@ -671,14 +673,13 @@ public static function providerAddColumnIdentity(): array return [ [PostgresAdapter::GENERATED_ALWAYS, true], //testAddColumnWithIdentityAlways [PostgresAdapter::GENERATED_BY_DEFAULT, false], //testAddColumnWithIdentityDefault - [null, true], //testAddColumnWithoutIdentity + [PostgresAdapter::GENERATED_BY_DEFAULT, true], ]; } #[DataProvider('providerAddColumnIdentity')] public function testAddColumnIdentity($generated, $addToColumn) { - $this->markTestIncomplete('Requires cakephp/database to use identity columns'); if (!$this->usingPostgres10()) { $this->markTestSkipped('Test Skipped because of PostgreSQL version is < 10.0'); } @@ -693,8 +694,8 @@ public function testAddColumnIdentity($generated, $addToColumn) $columns = $this->adapter->getColumns('table1'); foreach ($columns as $column) { if ($column->getName() === 'id') { - $this->assertEquals((bool)$generated, $column->getIdentity()); - $this->assertEquals($generated, $column->getGenerated()); + $this->assertEquals((bool)$generated, $column->getIdentity(), 'identity value does not match'); + $this->assertEquals($generated, $column->getGenerated(), 'generated value does not match'); } } } @@ -923,7 +924,6 @@ public static function providerChangeColumnIdentity(): array #[DataProvider('providerChangeColumnIdentity')] public function testChangeColumnIdentity($generated) { - $this->markTestIncomplete('Requires cakephp/database to use identity columns'); if (!$this->usingPostgres10()) { $this->markTestSkipped('Test Skipped because of PostgreSQL version is < 10.0'); } @@ -944,7 +944,6 @@ public function testChangeColumnIdentity($generated) public function testChangeColumnDropIdentity() { - $this->markTestIncomplete('Requires cakephp/database to use identity columns'); if (!$this->usingPostgres10()) { $this->markTestSkipped('Test Skipped because of PostgreSQL version is < 10.0'); } @@ -962,7 +961,6 @@ public function testChangeColumnDropIdentity() public function testChangeColumnChangeIdentity() { - $this->markTestIncomplete('Requires cakephp/database to use identity columns'); if (!$this->usingPostgres10()) { $this->markTestSkipped('Test Skipped because of PostgreSQL version is < 10.0'); } @@ -1875,54 +1873,6 @@ public function testDropAllSchemas() $this->assertFalse($this->adapter->hasSchema('bar')); } - public function testInvalidSqlType() - { - $this->expectException(UnsupportedColumnTypeException::class); - $this->expectExceptionMessage('Column type `idontexist` is not supported by Postgresql.'); - - $this->adapter->getSqlType('idontexist'); - } - - public function testGetPhinxType() - { - $this->assertEquals('integer', $this->adapter->getPhinxType('int')); - $this->assertEquals('integer', $this->adapter->getPhinxType('int4')); - $this->assertEquals('integer', $this->adapter->getPhinxType('integer')); - - $this->assertEquals('biginteger', $this->adapter->getPhinxType('bigint')); - $this->assertEquals('biginteger', $this->adapter->getPhinxType('int8')); - - $this->assertEquals('decimal', $this->adapter->getPhinxType('decimal')); - $this->assertEquals('decimal', $this->adapter->getPhinxType('numeric')); - - $this->assertEquals('float', $this->adapter->getPhinxType('real')); - $this->assertEquals('float', $this->adapter->getPhinxType('float4')); - - $this->assertEquals('double', $this->adapter->getPhinxType('double precision')); - - $this->assertEquals('boolean', $this->adapter->getPhinxType('bool')); - $this->assertEquals('boolean', $this->adapter->getPhinxType('boolean')); - - $this->assertEquals('string', $this->adapter->getPhinxType('character varying')); - $this->assertEquals('string', $this->adapter->getPhinxType('varchar')); - - $this->assertEquals('text', $this->adapter->getPhinxType('text')); - - $this->assertEquals('time', $this->adapter->getPhinxType('time')); - $this->assertEquals('time', $this->adapter->getPhinxType('timetz')); - $this->assertEquals('time', $this->adapter->getPhinxType('time with time zone')); - $this->assertEquals('time', $this->adapter->getPhinxType('time without time zone')); - - $this->assertEquals('datetime', $this->adapter->getPhinxType('timestamp')); - $this->assertEquals('datetime', $this->adapter->getPhinxType('timestamptz')); - $this->assertEquals('datetime', $this->adapter->getPhinxType('timestamp with time zone')); - $this->assertEquals('datetime', $this->adapter->getPhinxType('timestamp without time zone')); - - $this->assertEquals('uuid', $this->adapter->getPhinxType('uuid')); - - $this->assertEquals('interval', $this->adapter->getPhinxType('interval')); - } - public function testCreateTableWithComment() { $tableComment = 'Table comment'; @@ -2548,8 +2498,8 @@ public function testDumpCreateTableWithSchema() /** * Creates the table "table1". - * Then sets phinx to dry run mode and inserts a record. - * Asserts that phinx outputs the insert statement and doesn't insert a record. + * Then enables dry run mode and inserts a record. + * Asserts that output contains the insert statement and doesn't insert a record. */ public function testDumpInsert() { @@ -2603,8 +2553,8 @@ public function testDumpInsert() /** * Creates the table "table1". - * Then sets phinx to dry run mode and inserts some records. - * Asserts that phinx outputs the insert statement and doesn't insert any record. + * Then enables dry run mode and inserts some records. + * Asserts that output contains the insert statement and doesn't insert any record. */ public function testDumpBulkinsert() { @@ -2782,9 +2732,9 @@ public function testRenameMixedCaseTableAndColumns() public static function serialProvider(): array { return [ - [AdapterInterface::PHINX_TYPE_SMALL_INTEGER], - [AdapterInterface::PHINX_TYPE_INTEGER], - [AdapterInterface::PHINX_TYPE_BIG_INTEGER], + [AdapterInterface::TYPE_SMALLINTEGER], + [AdapterInterface::TYPE_INTEGER], + [AdapterInterface::TYPE_BIGINTEGER], ]; } @@ -2801,4 +2751,399 @@ public function testSerialAliases(string $columnType): void $this->assertTrue($column->isIdentity()); $this->assertFalse($column->isNull()); } + + public function testAddCheckConstraint() + { + $table = new Table('check_table', [], $this->adapter); + $table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->create(); + + $checkConstraint = new CheckConstraint('price_positive', 'price > 0'); + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); + + $this->assertTrue($this->adapter->hasCheckConstraint('check_table', 'price_positive')); + } + + public function testHasCheckConstraint() + { + $table = new Table('check_table3', [], $this->adapter); + $table->addColumn('quantity', 'integer') + ->create(); + + $checkConstraint = new CheckConstraint('quantity_positive', 'quantity > 0'); + $this->assertFalse($this->adapter->hasCheckConstraint('check_table3', 'quantity_positive')); + + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); + + $this->assertTrue($this->adapter->hasCheckConstraint('check_table3', 'quantity_positive')); + } + + public function testDropCheckConstraint() + { + $table = new Table('check_table4', [], $this->adapter); + $table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->create(); + + $checkConstraint = new CheckConstraint('price_check', 'price BETWEEN 0 AND 1000'); + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); + $this->assertTrue($this->adapter->hasCheckConstraint('check_table4', 'price_check')); + + $this->adapter->dropCheckConstraint('check_table4', 'price_check'); + $this->assertFalse($this->adapter->hasCheckConstraint('check_table4', 'price_check')); + } + + public function testCheckConstraintWithComplexExpression() + { + $table = new Table('check_table5', [], $this->adapter); + $table->addColumn('email', 'string', ['limit' => 255]) + ->addColumn('status', 'string', ['limit' => 20]) + ->create(); + + $checkConstraint = new CheckConstraint( + 'status_valid', + "status IN ('active', 'inactive', 'pending')", + ); + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); + $this->assertTrue($this->adapter->hasCheckConstraint('check_table5', 'status_valid')); + + // Verify the constraint is actually enforced + $quotedTableName = $this->adapter->getConnection()->getDriver()->quoteIdentifier('check_table5'); + $this->expectException(PDOException::class); + $this->adapter->execute("INSERT INTO {$quotedTableName} (email, status) VALUES ('test@example.com', 'invalid')"); + } + + public function testInsertOrSkipWithDuplicates() + { + $table = new Table('users', [], $this->adapter); + $table->addColumn('email', 'string', ['limit' => 255]) + ->addColumn('name', 'string') + ->addIndex('email', ['unique' => true]) + ->create(); + + // First insert + $table->insertOrSkip([ + ['email' => 'test@example.com', 'name' => 'John'], + ])->save(); + + // Duplicate - should be skipped + $table->insertOrSkip([ + ['email' => 'test@example.com', 'name' => 'Jane'], + ])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM users'); + $this->assertCount(1, $rows); + $this->assertEquals('John', $rows[0]['name']); + } + + public function testInsertModeResetsAfterInsertOrSkip() + { + $table = new Table('users', [], $this->adapter); + $table->addColumn('email', 'string', ['limit' => 255]) + ->addColumn('name', 'string') + ->addIndex('email', ['unique' => true]) + ->create(); + + // First insert with insertOrSkip + $table->insertOrSkip([ + ['email' => 'test@example.com', 'name' => 'John'], + ])->save(); + + // Now use regular insert with duplicate - should throw exception + $this->expectException(PDOException::class); + $table->insert([ + ['email' => 'test@example.com', 'name' => 'Jane'], + ])->save(); + } + + public function testBulkinsertOrSkipWithDuplicates() + { + $table = new Table('products', [], $this->adapter); + $table->addColumn('sku', 'string', ['limit' => 50]) + ->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->addIndex('sku', ['unique' => true]) + ->create(); + + // First bulk insert + $table->insertOrSkip([ + ['sku' => 'ABC123', 'price' => 10.50], + ['sku' => 'DEF456', 'price' => 20.00], + ])->save(); + + // Mix of new and duplicate - duplicates should be skipped + $table->insertOrSkip([ + ['sku' => 'ABC123', 'price' => 99.99], // Duplicate + ['sku' => 'GHI789', 'price' => 30.00], // New + ])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM products ORDER BY sku'); + $this->assertCount(3, $rows); + $this->assertEquals('10.50', $rows[0]['price']); // Original price preserved + $this->assertEquals('20.00', $rows[1]['price']); + $this->assertEquals('30.00', $rows[2]['price']); + } + + public function testInsertOrSkipWithoutDuplicates() + { + $table = new Table('categories', [], $this->adapter); + $table->addColumn('name', 'string') + ->create(); + + // Should work like normal insert + $table->insertOrSkip([ + ['name' => 'Category 1'], + ['name' => 'Category 2'], + ])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM categories'); + $this->assertCount(2, $rows); + } + + public function testInsertOrUpdateWithDuplicates() + { + $table = new Table('currencies', [], $this->adapter); + $table->addColumn('code', 'string', ['limit' => 3]) + ->addColumn('rate', 'decimal', ['precision' => 10, 'scale' => 4]) + ->addIndex('code', ['unique' => true]) + ->create(); + + // First insert + $table->insertOrUpdate([ + ['code' => 'USD', 'rate' => 1.0000], + ['code' => 'EUR', 'rate' => 0.9000], + ], ['rate'], ['code'])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM currencies ORDER BY code'); + $this->assertCount(2, $rows); + $this->assertEquals('0.9000', $rows[0]['rate']); // EUR + $this->assertEquals('1.0000', $rows[1]['rate']); // USD + + // Update rates - should update existing rows + $table->insertOrUpdate([ + ['code' => 'USD', 'rate' => 1.0500], + ['code' => 'EUR', 'rate' => 0.9234], + ['code' => 'GBP', 'rate' => 0.7800], // New row + ], ['rate'], ['code'])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM currencies ORDER BY code'); + $this->assertCount(3, $rows); + $this->assertEquals('0.9234', $rows[0]['rate']); // EUR updated + $this->assertEquals('0.7800', $rows[1]['rate']); // GBP new + $this->assertEquals('1.0500', $rows[2]['rate']); // USD updated + } + + public function testInsertOrUpdateWithMultipleUpdateColumns() + { + $table = new Table('products', [], $this->adapter); + $table->addColumn('sku', 'string', ['limit' => 50]) + ->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->addColumn('stock', 'integer') + ->addIndex('sku', ['unique' => true]) + ->create(); + + // First insert + $table->insertOrUpdate([ + ['sku' => 'ABC123', 'price' => 10.00, 'stock' => 100], + ], ['price', 'stock'], ['sku'])->save(); + + // Update both price and stock + $table->insertOrUpdate([ + ['sku' => 'ABC123', 'price' => 15.00, 'stock' => 50], + ], ['price', 'stock'], ['sku'])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM products'); + $this->assertCount(1, $rows); + $this->assertEquals('15.00', $rows[0]['price']); + $this->assertEquals(50, $rows[0]['stock']); + } + + public function testInsertOrUpdateModeResetsAfterSave() + { + $table = new Table('items', [], $this->adapter); + $table->addColumn('code', 'string', ['limit' => 10]) + ->addColumn('name', 'string') + ->addIndex('code', ['unique' => true]) + ->create(); + + // Use insertOrUpdate + $table->insertOrUpdate([ + ['code' => 'ITEM1', 'name' => 'Item One'], + ], ['name'], ['code'])->save(); + + // Now use regular insert with duplicate - should throw exception + $this->expectException(PDOException::class); + $table->insert([ + ['code' => 'ITEM1', 'name' => 'Different Name'], + ])->save(); + } + + public function testInsertOrUpdateRequiresConflictColumns() + { + $table = new Table('currencies', [], $this->adapter); + $table->addColumn('code', 'string', ['limit' => 3]) + ->addColumn('rate', 'decimal', ['precision' => 10, 'scale' => 4]) + ->addIndex('code', ['unique' => true]) + ->create(); + + // PostgreSQL requires conflictColumns for insertOrUpdate + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('PostgreSQL requires the $conflictColumns parameter'); + $table->insertOrUpdate([ + ['code' => 'USD', 'rate' => 1.0000], + ], ['rate'], [])->save(); + } + + public function testAddSinglePartitionToExistingTable() + { + // Create a partitioned table with room to add more partitions + $table = new Table('partitioned_orders', ['id' => false, 'primary_key' => ['id', 'order_date']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('order_date', 'date') + ->addColumn('amount', 'decimal', ['precision' => 10, 'scale' => 2]) + ->partitionBy(Partition::TYPE_RANGE, 'order_date') + ->addPartition('p2022', ['from' => '2022-01-01', 'to' => '2023-01-01']) + ->addPartition('p2023', ['from' => '2023-01-01', 'to' => '2024-01-01']) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_orders')); + + // Add a new partition to the existing table + $table = new Table('partitioned_orders', [], $this->adapter); + $table->addPartitionToExisting('p2024', ['from' => '2024-01-01', 'to' => '2025-01-01']) + ->save(); + + // Verify the partition was added by inserting data that belongs in the new partition + $this->adapter->execute( + "INSERT INTO partitioned_orders (id, order_date, amount) VALUES (1, '2024-06-15', 100.00)", + ); + + $rows = $this->adapter->fetchAll("SELECT * FROM partitioned_orders WHERE order_date = '2024-06-15'"); + $this->assertCount(1, $rows); + + // Cleanup - drop partitioned table (CASCADE drops partitions) + $this->adapter->dropTable('partitioned_orders'); + } + + public function testAddMultiplePartitionsToExistingTable() + { + // Create a partitioned table + $table = new Table('partitioned_sales', ['id' => false, 'primary_key' => ['id', 'sale_date']], $this->adapter); + $table->addColumn('id', 'integer') + ->addColumn('sale_date', 'date') + ->addColumn('amount', 'decimal', ['precision' => 10, 'scale' => 2]) + ->partitionBy(Partition::TYPE_RANGE, 'sale_date') + ->addPartition('p2022', ['from' => '2022-01-01', 'to' => '2023-01-01']) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_sales')); + + // Add multiple partitions at once + $table = new Table('partitioned_sales', [], $this->adapter); + $table->addPartitionToExisting('p2023', ['from' => '2023-01-01', 'to' => '2024-01-01']) + ->addPartitionToExisting('p2024', ['from' => '2024-01-01', 'to' => '2025-01-01']) + ->addPartitionToExisting('p2025', ['from' => '2025-01-01', 'to' => '2026-01-01']) + ->save(); + + // Verify all partitions were added by inserting data into each + $this->adapter->execute( + "INSERT INTO partitioned_sales (id, sale_date, amount) VALUES (1, '2023-06-15', 100.00)", + ); + $this->adapter->execute( + "INSERT INTO partitioned_sales (id, sale_date, amount) VALUES (2, '2024-06-15', 200.00)", + ); + $this->adapter->execute( + "INSERT INTO partitioned_sales (id, sale_date, amount) VALUES (3, '2025-06-15', 300.00)", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_sales'); + $this->assertCount(3, $rows); + + // Cleanup + $this->adapter->dropTable('partitioned_sales'); + } + + public function testDropSinglePartitionFromExistingTable() + { + // Create a partitioned table with multiple partitions + $table = new Table('partitioned_logs', ['id' => false, 'primary_key' => ['id']], $this->adapter); + $table->addColumn('id', 'biginteger') + ->addColumn('message', 'text') + ->partitionBy(Partition::TYPE_RANGE, 'id') + ->addPartition('p0', ['from' => 0, 'to' => 1000000]) + ->addPartition('p1', ['from' => 1000000, 'to' => 2000000]) + ->addPartition('p2', ['from' => 2000000, 'to' => 3000000]) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_logs')); + + // Insert data into partition p0 + $this->adapter->execute( + "INSERT INTO partitioned_logs (id, message) VALUES (500, 'test message')", + ); + + // Drop the partition (this also removes the data in PostgreSQL) + $table = new Table('partitioned_logs', [], $this->adapter); + $table->dropPartition('p0') + ->save(); + + // Verify the partition table was dropped + $this->assertFalse($this->adapter->hasTable('partitioned_logs_p0')); + + // Verify the main partitioned table still exists + $this->assertTrue($this->adapter->hasTable('partitioned_logs')); + + // Verify the table still works by inserting into the next partition + $this->adapter->execute( + "INSERT INTO partitioned_logs (id, message) VALUES (1500000, 'another message')", + ); + + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_logs WHERE id = 1500000'); + $this->assertCount(1, $rows); + + // Cleanup - drop partitioned table (CASCADE drops remaining partitions) + $this->adapter->dropTable('partitioned_logs'); + } + + public function testDropMultiplePartitionsFromExistingTable() + { + // Create a partitioned table with multiple partitions + $table = new Table('partitioned_archive', ['id' => false, 'primary_key' => ['id']], $this->adapter); + $table->addColumn('id', 'biginteger') + ->addColumn('data', 'text') + ->partitionBy(Partition::TYPE_RANGE, 'id') + ->addPartition('p0', ['from' => 0, 'to' => 1000000]) + ->addPartition('p1', ['from' => 1000000, 'to' => 2000000]) + ->addPartition('p2', ['from' => 2000000, 'to' => 3000000]) + ->addPartition('p3', ['from' => 3000000, 'to' => 4000000]) + ->create(); + + $this->assertTrue($this->adapter->hasTable('partitioned_archive')); + + // Insert data into partitions + $this->adapter->execute( + "INSERT INTO partitioned_archive (id, data) VALUES (500, 'data in p0')", + ); + $this->adapter->execute( + "INSERT INTO partitioned_archive (id, data) VALUES (1500000, 'data in p1')", + ); + $this->adapter->execute( + "INSERT INTO partitioned_archive (id, data) VALUES (2500000, 'data in p2')", + ); + + // Drop multiple partitions at once + $table = new Table('partitioned_archive', [], $this->adapter); + $table->dropPartition('p0') + ->dropPartition('p1') + ->save(); + + // Verify the partition tables were dropped + $this->assertFalse($this->adapter->hasTable('partitioned_archive_p0')); + $this->assertFalse($this->adapter->hasTable('partitioned_archive_p1')); + + // Verify data in p2 still exists + $rows = $this->adapter->fetchAll('SELECT * FROM partitioned_archive WHERE id = 2500000'); + $this->assertCount(1, $rows); + + // Cleanup + $this->adapter->dropTable('partitioned_archive'); + } } diff --git a/tests/TestCase/Db/Adapter/SqliteAdapterTest.php b/tests/TestCase/Db/Adapter/SqliteAdapterTest.php index 1a5cbf734..2fd54a9bd 100644 --- a/tests/TestCase/Db/Adapter/SqliteAdapterTest.php +++ b/tests/TestCase/Db/Adapter/SqliteAdapterTest.php @@ -10,13 +10,12 @@ use Cake\Database\Connection; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\TestCase; -use Exception; use InvalidArgumentException; use Migrations\Db\Adapter\SqliteAdapter; -use Migrations\Db\Adapter\UnsupportedColumnTypeException; use Migrations\Db\Expression; use Migrations\Db\Literal; use Migrations\Db\Table; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; @@ -218,7 +217,8 @@ public function testCreateTableWithMultiplePrimaryKeys() ->addColumn('tag_id', 'integer') ->save(); $this->assertTrue($this->adapter->hasIndex('table1', ['user_id', 'tag_id'])); - $this->assertTrue($this->adapter->hasIndex('table1', ['USER_ID', 'tag_id'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['USER_ID', 'tag_id'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_id'])); $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'USER_ID'])); $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_email'])); } @@ -428,6 +428,51 @@ public function testChangePrimaryKeyNonInteger() $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['column2'])); } + public function testChangePrimaryKeyWithoutAutoIncrement() + { + // Create table with id_1 as PK without AUTOINCREMENT keyword + $this->adapter->execute('CREATE TABLE table1 (id_1 INTEGER NOT NULL PRIMARY KEY, id_2 INTEGER NOT NULL)'); + + // Verify initial SQL does not have AUTOINCREMENT + $result = $this->adapter->fetchRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='table1'"); + $this->assertStringNotContainsString('AUTOINCREMENT', $result['sql']); + + // Change primary key to id_2 + $table = new Table('table1', [], $this->adapter); + $table->changePrimaryKey('id_2')->save(); + + // Verify primary key changed + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['id_1'])); + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['id_2'])); + + // Verify the SQL does NOT have AUTOINCREMENT added to id_2 + $result = $this->adapter->fetchRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='table1'"); + $this->assertStringNotContainsString('AUTOINCREMENT', $result['sql'], 'AUTOINCREMENT should not be added when changing PK to a column that did not have it'); + } + + public function testChangePrimaryKeyFromAutoIncrementColumn() + { + // Create table with id_1 as PK with AUTOINCREMENT + $this->adapter->execute('CREATE TABLE table1 (id_1 INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id_2 INTEGER NOT NULL)'); + + // Verify initial SQL has AUTOINCREMENT + $result = $this->adapter->fetchRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='table1'"); + $this->assertStringContainsString('AUTOINCREMENT', $result['sql']); + + // Change primary key to id_2 (should NOT get AUTOINCREMENT since id_2 doesn't have it) + $table = new Table('table1', [], $this->adapter); + $table->changePrimaryKey('id_2')->save(); + + // Verify primary key changed + $this->assertFalse($this->adapter->hasPrimaryKey('table1', ['id_1'])); + $this->assertTrue($this->adapter->hasPrimaryKey('table1', ['id_2'])); + + // Verify the SQL does NOT have AUTOINCREMENT on id_2 + // (id_1 lost its AUTOINCREMENT when PK was dropped, and id_2 never had it) + $result = $this->adapter->fetchRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='table1'"); + $this->assertStringNotContainsString('AUTOINCREMENT', $result['sql'], 'AUTOINCREMENT should not be added when changing PK to a column that never had it'); + } + public function testDropPrimaryKey() { $table = new Table('table1', ['id' => false, 'primary_key' => 'column1'], $this->adapter); @@ -1523,7 +1568,7 @@ public function testDropForeignKeyCaseInsensitivity() ->addForeignKey(['ref_table_id'], 'ref_table', ['id']) ->save(); - $this->adapter->dropForeignKey($table->getName(), ['REF_TABLE_ID']); + $this->adapter->dropForeignKey($table->getName(), ['ref_table_id']); $this->assertFalse($this->adapter->hasForeignKey($table->getName(), ['ref_table_id'])); } @@ -1609,28 +1654,6 @@ public function testAddColumnTableWithConstraint() $this->assertEquals('short desc', $res['description']); } - public function testPhinxTypeLiteral() - { - $this->assertEquals( - [ - 'name' => Literal::from('fake'), - 'limit' => null, - 'scale' => null, - ], - $this->adapter->getPhinxType('fake'), - ); - } - - public function testPhinxTypeNotValidTypeRegex() - { - $exp = [ - 'name' => Literal::from('?int?'), - 'limit' => null, - 'scale' => null, - ]; - $this->assertEquals($exp, $this->adapter->getPhinxType('?int?')); - } - public function testAddIndexTwoTablesSameIndex() { $table = new Table('table1', [], $this->adapter); @@ -2379,6 +2402,27 @@ public static function provideTableNamesForPresenceCheck() ]; } + /** + * Test that hasTable() returns false after a table is dropped via execute(). + * + * This verifies that hasTable() always checks the database rather than + * relying on an internal cache that could become stale when raw SQL is used. + */ + public function testHasTableAfterExecuteDrop(): void + { + // Create table via API + $table = new Table('cache_test', [], $this->adapter); + $table->addColumn('name', 'string') + ->save(); + + $this->assertTrue($this->adapter->hasTable('cache_test')); + + // Drop via execute() - hasTable() must still return false + $this->adapter->execute('DROP TABLE "cache_test"'); + + $this->assertFalse($this->adapter->hasTable('cache_test')); + } + #[DataProvider('provideIndexColumnsToCheck')] public function testHasIndex($tableDef, $cols, $exp) { @@ -2410,9 +2454,10 @@ public static function provideIndexColumnsToCheck() ['create table t(a text, b text); create index test on t(a,b)', ['b', 'a'], false], ['create table t(a text, b text); create index test on t(a,b)', ['a'], false], ['create table t(a text, b text); create index test on t(a)', ['a', 'b'], false], - ['create table t(a text, b text); create index test on t(a,b)', ['A', 'B'], true], - ['create table t("A" text, "B" text); create index test on t("A","B")', ['a', 'b'], true], - ['create table not_t(a text, b text, unique(a,b))', ['A', 'B'], false], // test checks table t which does not exist + ['create table t(a text, b text); create index test on t(a,b)', ['A', 'B'], false], + ['create table t(a text, b text); create index test on t(a,b)', ['a', 'b'], true], + ['create table t("A" text, "B" text); create index test on t("A","B")', ['A', 'B'], true], + ['create table not_t(a text, b text, unique(a,b))', ['a', 'b'], false], // test checks table t which does not exist ['create table t(a text, b text); create index test on t(a)', ['a', 'a'], false], ['create table t(a text unique); create temp table t(a text)', 'a', false], ]; @@ -2440,8 +2485,9 @@ public static function provideIndexNamesToCheck() return [ ['create table t(a text)', 'test', false], ['create table t(a text); create index test on t(a)', 'test', true], - ['create table t(a text); create index test on t(a)', 'TEST', true], - ['create table t(a text); create index "TEST" on t(a)', 'test', true], + ['create table t(a text); create index test on t(a)', 'TEST', false], + ['create table t(a text); create index "TEST" on t(a)', 'test', false], + ['create table t(a text); create index "TEST" on t(a)', 'TEST', true], ['create table t(a text unique)', 'sqlite_autoindex_t_1', true], ['create table t(a text primary key)', 'sqlite_autoindex_t_1', true], ['create table not_t(a text); create index test on not_t(a)', 'test', false], // test checks table t which does not exist @@ -2549,8 +2595,9 @@ public static function provideForeignKeysToCheck() ['create table t(a integer, b integer, foreign key(a,b) references other(a,b))', 'a', false], ['create table t(a integer, b integer, foreign key(a,b) references other(a,b))', ['a', 'b'], true], ['create table t(a integer, b integer, foreign key(a,b) references other(a,b))', ['b', 'a'], false], - ['create table t(a integer, "B" integer, foreign key(a,"B") references other(a,b))', ['a', 'b'], true], - ['create table t(a integer, b integer, foreign key(a,b) references other(a,b))', ['a', 'B'], true], + ['create table t(a integer, "B" integer, foreign key(a,"B") references other(a,b))', ['a', 'B'], true], + ['create table t(a integer, b integer, foreign key(a,b) references other(a,b))', ['a', 'b'], true], + ['create table t(a integer, b integer, foreign key(a,b) references other(a,b))', ['a', 'B'], false], ['create table t(a integer, b integer, c integer, foreign key(a,b,c) references other(a,b,c))', ['a', 'b'], false], ['create table t(a integer, foreign key(a) references other(a))', ['a', 'b'], false], ['create table t(a integer references other(a), b integer references other(b))', ['a', 'b'], false], @@ -2675,273 +2722,6 @@ public static function hasNamedForeignKeyProvider(): array ]; } - #[DataProvider('providePhinxTypes')] - public function testGetSqlType($phinxType, $limit, $exp) - { - if ($exp instanceof Exception) { - $this->expectException(get_class($exp)); - - $this->adapter->getSqlType($phinxType, $limit); - } else { - $exp = ['name' => $exp, 'limit' => $limit]; - $this->assertEquals($exp, $this->adapter->getSqlType($phinxType, $limit)); - } - } - - public static function providePhinxTypes() - { - $unsupported = new UnsupportedColumnTypeException(); - - return [ - [SqliteAdapter::PHINX_TYPE_BIG_INTEGER, null, SqliteAdapter::PHINX_TYPE_BIG_INTEGER], - [SqliteAdapter::PHINX_TYPE_BINARY, null, SqliteAdapter::PHINX_TYPE_BINARY . '_blob'], - [SqliteAdapter::PHINX_TYPE_BIT, null, $unsupported], - [SqliteAdapter::PHINX_TYPE_BLOB, null, SqliteAdapter::PHINX_TYPE_BLOB], - [SqliteAdapter::PHINX_TYPE_BOOLEAN, null, SqliteAdapter::PHINX_TYPE_BOOLEAN . '_integer'], - [SqliteAdapter::PHINX_TYPE_CHAR, null, SqliteAdapter::PHINX_TYPE_CHAR], - [SqliteAdapter::PHINX_TYPE_CIDR, null, $unsupported], - [SqliteAdapter::PHINX_TYPE_DATE, null, SqliteAdapter::PHINX_TYPE_DATE . '_text'], - [SqliteAdapter::PHINX_TYPE_DATETIME, null, SqliteAdapter::PHINX_TYPE_DATETIME . '_text'], - [SqliteAdapter::PHINX_TYPE_DECIMAL, null, SqliteAdapter::PHINX_TYPE_DECIMAL], - [SqliteAdapter::PHINX_TYPE_DOUBLE, null, SqliteAdapter::PHINX_TYPE_DOUBLE], - [SqliteAdapter::PHINX_TYPE_ENUM, null, $unsupported], - [SqliteAdapter::PHINX_TYPE_FILESTREAM, null, $unsupported], - [SqliteAdapter::PHINX_TYPE_FLOAT, null, SqliteAdapter::PHINX_TYPE_FLOAT], - [SqliteAdapter::PHINX_TYPE_GEOMETRY, null, $unsupported], - [SqliteAdapter::PHINX_TYPE_INET, null, $unsupported], - [SqliteAdapter::PHINX_TYPE_INTEGER, null, SqliteAdapter::PHINX_TYPE_INTEGER], - [SqliteAdapter::PHINX_TYPE_INTERVAL, null, $unsupported], - [SqliteAdapter::PHINX_TYPE_JSON, null, SqliteAdapter::PHINX_TYPE_JSON . '_text'], - [SqliteAdapter::PHINX_TYPE_JSONB, null, SqliteAdapter::PHINX_TYPE_JSONB . '_text'], - [SqliteAdapter::PHINX_TYPE_LINESTRING, null, $unsupported], - [SqliteAdapter::PHINX_TYPE_MACADDR, null, $unsupported], - [SqliteAdapter::PHINX_TYPE_POINT, null, $unsupported], - [SqliteAdapter::PHINX_TYPE_POLYGON, null, $unsupported], - [SqliteAdapter::PHINX_TYPE_SET, null, $unsupported], - [SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, null, SqliteAdapter::PHINX_TYPE_SMALL_INTEGER], - [SqliteAdapter::PHINX_TYPE_STRING, null, 'varchar'], - [SqliteAdapter::PHINX_TYPE_TEXT, null, SqliteAdapter::PHINX_TYPE_TEXT], - [SqliteAdapter::PHINX_TYPE_TIME, null, SqliteAdapter::PHINX_TYPE_TIME . '_text'], - [SqliteAdapter::PHINX_TYPE_TIMESTAMP, null, SqliteAdapter::PHINX_TYPE_TIMESTAMP . '_text'], - [SqliteAdapter::PHINX_TYPE_UUID, null, SqliteAdapter::PHINX_TYPE_UUID . '_text'], - [SqliteAdapter::PHINX_TYPE_VARBINARY, null, SqliteAdapter::PHINX_TYPE_VARBINARY . '_blob'], - [SqliteAdapter::PHINX_TYPE_STRING, 5, 'varchar'], - [Literal::from('someType'), 5, Literal::from('someType')], - ['notAType', null, $unsupported], - ]; - } - - #[DataProvider('provideSqlTypes')] - public function testGetPhinxType($sqlType, $exp) - { - $this->assertEquals($exp, $this->adapter->getPhinxType($sqlType)); - } - - /** - * @return array - */ - public static function provideSqlTypes() - { - return [ - ['varchar', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => null, 'scale' => null]], - ['string', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => null, 'scale' => null]], - ['string_text', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => null, 'scale' => null]], - ['varchar(5)', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => 5, 'scale' => null]], - ['varchar(55,2)', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => 55, 'scale' => 2]], - ['char', ['name' => SqliteAdapter::PHINX_TYPE_CHAR, 'limit' => null, 'scale' => null]], - ['boolean', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], - ['boolean_integer', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], - ['int', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], - ['integer', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], - ['tinyint', ['name' => SqliteAdapter::PHINX_TYPE_TINY_INTEGER, 'limit' => null, 'scale' => null]], - ['tinyint(1)', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], - ['tinyinteger', ['name' => SqliteAdapter::PHINX_TYPE_TINY_INTEGER, 'limit' => null, 'scale' => null]], - ['tinyinteger(1)', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], - ['smallint', ['name' => SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, 'limit' => null, 'scale' => null]], - ['smallinteger', ['name' => SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, 'limit' => null, 'scale' => null]], - ['mediumint', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], - ['mediuminteger', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], - ['bigint', ['name' => SqliteAdapter::PHINX_TYPE_BIG_INTEGER, 'limit' => null, 'scale' => null]], - ['biginteger', ['name' => SqliteAdapter::PHINX_TYPE_BIG_INTEGER, 'limit' => null, 'scale' => null]], - ['text', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], - ['tinytext', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], - ['mediumtext', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], - ['longtext', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], - ['blob', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], - ['tinyblob', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], - ['mediumblob', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], - ['longblob', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], - ['float', ['name' => SqliteAdapter::PHINX_TYPE_FLOAT, 'limit' => null, 'scale' => null]], - ['real', ['name' => SqliteAdapter::PHINX_TYPE_FLOAT, 'limit' => null, 'scale' => null]], - ['double', ['name' => SqliteAdapter::PHINX_TYPE_DOUBLE, 'limit' => null, 'scale' => null]], - ['date', ['name' => SqliteAdapter::PHINX_TYPE_DATE, 'limit' => null, 'scale' => null]], - ['date_text', ['name' => SqliteAdapter::PHINX_TYPE_DATE, 'limit' => null, 'scale' => null]], - ['datetime', ['name' => SqliteAdapter::PHINX_TYPE_DATETIME, 'limit' => null, 'scale' => null]], - ['datetime_text', ['name' => SqliteAdapter::PHINX_TYPE_DATETIME, 'limit' => null, 'scale' => null]], - ['time', ['name' => SqliteAdapter::PHINX_TYPE_TIME, 'limit' => null, 'scale' => null]], - ['time_text', ['name' => SqliteAdapter::PHINX_TYPE_TIME, 'limit' => null, 'scale' => null]], - ['timestamp', ['name' => SqliteAdapter::PHINX_TYPE_TIMESTAMP, 'limit' => null, 'scale' => null]], - ['timestamp_text', ['name' => SqliteAdapter::PHINX_TYPE_TIMESTAMP, 'limit' => null, 'scale' => null]], - ['binary', ['name' => SqliteAdapter::PHINX_TYPE_BINARY, 'limit' => null, 'scale' => null]], - ['binary_blob', ['name' => SqliteAdapter::PHINX_TYPE_BINARY, 'limit' => null, 'scale' => null]], - ['varbinary', ['name' => SqliteAdapter::PHINX_TYPE_VARBINARY, 'limit' => null, 'scale' => null]], - ['varbinary_blob', ['name' => SqliteAdapter::PHINX_TYPE_VARBINARY, 'limit' => null, 'scale' => null]], - ['json', ['name' => SqliteAdapter::PHINX_TYPE_JSON, 'limit' => null, 'scale' => null]], - ['json_text', ['name' => SqliteAdapter::PHINX_TYPE_JSON, 'limit' => null, 'scale' => null]], - ['jsonb', ['name' => SqliteAdapter::PHINX_TYPE_JSONB, 'limit' => null, 'scale' => null]], - ['jsonb_text', ['name' => SqliteAdapter::PHINX_TYPE_JSONB, 'limit' => null, 'scale' => null]], - ['uuid', ['name' => SqliteAdapter::PHINX_TYPE_UUID, 'limit' => null, 'scale' => null]], - ['uuid_text', ['name' => SqliteAdapter::PHINX_TYPE_UUID, 'limit' => null, 'scale' => null]], - ['decimal', ['name' => Literal::from('decimal'), 'limit' => null, 'scale' => null]], - ['point', ['name' => Literal::from('point'), 'limit' => null, 'scale' => null]], - ['polygon', ['name' => Literal::from('polygon'), 'limit' => null, 'scale' => null]], - ['linestring', ['name' => Literal::from('linestring'), 'limit' => null, 'scale' => null]], - ['geometry', ['name' => Literal::from('geometry'), 'limit' => null, 'scale' => null]], - ['bit', ['name' => Literal::from('bit'), 'limit' => null, 'scale' => null]], - ['enum', ['name' => Literal::from('enum'), 'limit' => null, 'scale' => null]], - ['set', ['name' => Literal::from('set'), 'limit' => null, 'scale' => null]], - ['cidr', ['name' => Literal::from('cidr'), 'limit' => null, 'scale' => null]], - ['inet', ['name' => Literal::from('inet'), 'limit' => null, 'scale' => null]], - ['macaddr', ['name' => Literal::from('macaddr'), 'limit' => null, 'scale' => null]], - ['interval', ['name' => Literal::from('interval'), 'limit' => null, 'scale' => null]], - ['filestream', ['name' => Literal::from('filestream'), 'limit' => null, 'scale' => null]], - ['decimal_text', ['name' => Literal::from('decimal'), 'limit' => null, 'scale' => null]], - ['point_text', ['name' => Literal::from('point'), 'limit' => null, 'scale' => null]], - ['polygon_text', ['name' => Literal::from('polygon'), 'limit' => null, 'scale' => null]], - ['linestring_text', ['name' => Literal::from('linestring'), 'limit' => null, 'scale' => null]], - ['geometry_text', ['name' => Literal::from('geometry'), 'limit' => null, 'scale' => null]], - ['bit_text', ['name' => Literal::from('bit'), 'limit' => null, 'scale' => null]], - ['enum_text', ['name' => Literal::from('enum'), 'limit' => null, 'scale' => null]], - ['set_text', ['name' => Literal::from('set'), 'limit' => null, 'scale' => null]], - ['cidr_text', ['name' => Literal::from('cidr'), 'limit' => null, 'scale' => null]], - ['inet_text', ['name' => Literal::from('inet'), 'limit' => null, 'scale' => null]], - ['macaddr_text', ['name' => Literal::from('macaddr'), 'limit' => null, 'scale' => null]], - ['interval_text', ['name' => Literal::from('interval'), 'limit' => null, 'scale' => null]], - ['filestream_text', ['name' => Literal::from('filestream'), 'limit' => null, 'scale' => null]], - ['bit_text(2,12)', ['name' => Literal::from('bit'), 'limit' => 2, 'scale' => 12]], - ['VARCHAR', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => null, 'scale' => null]], - ['STRING', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => null, 'scale' => null]], - ['STRING_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => null, 'scale' => null]], - ['VARCHAR(5)', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => 5, 'scale' => null]], - ['VARCHAR(55,2)', ['name' => SqliteAdapter::PHINX_TYPE_STRING, 'limit' => 55, 'scale' => 2]], - ['CHAR', ['name' => SqliteAdapter::PHINX_TYPE_CHAR, 'limit' => null, 'scale' => null]], - ['BOOLEAN', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], - ['BOOLEAN_INTEGER', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], - ['INT', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], - ['INTEGER', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], - ['TINYINT', ['name' => SqliteAdapter::PHINX_TYPE_TINY_INTEGER, 'limit' => null, 'scale' => null]], - ['TINYINT(1)', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], - ['TINYINTEGER', ['name' => SqliteAdapter::PHINX_TYPE_TINY_INTEGER, 'limit' => null, 'scale' => null]], - ['TINYINTEGER(1)', ['name' => SqliteAdapter::PHINX_TYPE_BOOLEAN, 'limit' => null, 'scale' => null]], - ['SMALLINT', ['name' => SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, 'limit' => null, 'scale' => null]], - ['SMALLINTEGER', ['name' => SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, 'limit' => null, 'scale' => null]], - ['MEDIUMINT', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], - ['MEDIUMINTEGER', ['name' => SqliteAdapter::PHINX_TYPE_INTEGER, 'limit' => null, 'scale' => null]], - ['BIGINT', ['name' => SqliteAdapter::PHINX_TYPE_BIG_INTEGER, 'limit' => null, 'scale' => null]], - ['BIGINTEGER', ['name' => SqliteAdapter::PHINX_TYPE_BIG_INTEGER, 'limit' => null, 'scale' => null]], - ['TEXT', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], - ['TINYTEXT', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], - ['MEDIUMTEXT', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], - ['LONGTEXT', ['name' => SqliteAdapter::PHINX_TYPE_TEXT, 'limit' => null, 'scale' => null]], - ['BLOB', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], - ['TINYBLOB', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], - ['MEDIUMBLOB', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], - ['LONGBLOB', ['name' => SqliteAdapter::PHINX_TYPE_BLOB, 'limit' => null, 'scale' => null]], - ['FLOAT', ['name' => SqliteAdapter::PHINX_TYPE_FLOAT, 'limit' => null, 'scale' => null]], - ['REAL', ['name' => SqliteAdapter::PHINX_TYPE_FLOAT, 'limit' => null, 'scale' => null]], - ['DOUBLE', ['name' => SqliteAdapter::PHINX_TYPE_DOUBLE, 'limit' => null, 'scale' => null]], - ['DATE', ['name' => SqliteAdapter::PHINX_TYPE_DATE, 'limit' => null, 'scale' => null]], - ['DATE_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_DATE, 'limit' => null, 'scale' => null]], - ['DATETIME', ['name' => SqliteAdapter::PHINX_TYPE_DATETIME, 'limit' => null, 'scale' => null]], - ['DATETIME_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_DATETIME, 'limit' => null, 'scale' => null]], - ['TIME', ['name' => SqliteAdapter::PHINX_TYPE_TIME, 'limit' => null, 'scale' => null]], - ['TIME_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_TIME, 'limit' => null, 'scale' => null]], - ['TIMESTAMP', ['name' => SqliteAdapter::PHINX_TYPE_TIMESTAMP, 'limit' => null, 'scale' => null]], - ['TIMESTAMP_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_TIMESTAMP, 'limit' => null, 'scale' => null]], - ['BINARY', ['name' => SqliteAdapter::PHINX_TYPE_BINARY, 'limit' => null, 'scale' => null]], - ['BINARY_BLOB', ['name' => SqliteAdapter::PHINX_TYPE_BINARY, 'limit' => null, 'scale' => null]], - ['VARBINARY', ['name' => SqliteAdapter::PHINX_TYPE_VARBINARY, 'limit' => null, 'scale' => null]], - ['VARBINARY_BLOB', ['name' => SqliteAdapter::PHINX_TYPE_VARBINARY, 'limit' => null, 'scale' => null]], - ['JSON', ['name' => SqliteAdapter::PHINX_TYPE_JSON, 'limit' => null, 'scale' => null]], - ['JSON_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_JSON, 'limit' => null, 'scale' => null]], - ['JSONB', ['name' => SqliteAdapter::PHINX_TYPE_JSONB, 'limit' => null, 'scale' => null]], - ['JSONB_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_JSONB, 'limit' => null, 'scale' => null]], - ['UUID', ['name' => SqliteAdapter::PHINX_TYPE_UUID, 'limit' => null, 'scale' => null]], - ['UUID_TEXT', ['name' => SqliteAdapter::PHINX_TYPE_UUID, 'limit' => null, 'scale' => null]], - ['DECIMAL', ['name' => Literal::from('decimal'), 'limit' => null, 'scale' => null]], - ['POINT', ['name' => Literal::from('point'), 'limit' => null, 'scale' => null]], - ['POLYGON', ['name' => Literal::from('polygon'), 'limit' => null, 'scale' => null]], - ['LINESTRING', ['name' => Literal::from('linestring'), 'limit' => null, 'scale' => null]], - ['GEOMETRY', ['name' => Literal::from('geometry'), 'limit' => null, 'scale' => null]], - ['BIT', ['name' => Literal::from('bit'), 'limit' => null, 'scale' => null]], - ['ENUM', ['name' => Literal::from('enum'), 'limit' => null, 'scale' => null]], - ['SET', ['name' => Literal::from('set'), 'limit' => null, 'scale' => null]], - ['CIDR', ['name' => Literal::from('cidr'), 'limit' => null, 'scale' => null]], - ['INET', ['name' => Literal::from('inet'), 'limit' => null, 'scale' => null]], - ['MACADDR', ['name' => Literal::from('macaddr'), 'limit' => null, 'scale' => null]], - ['INTERVAL', ['name' => Literal::from('interval'), 'limit' => null, 'scale' => null]], - ['FILESTREAM', ['name' => Literal::from('filestream'), 'limit' => null, 'scale' => null]], - ['DECIMAL_TEXT', ['name' => Literal::from('decimal'), 'limit' => null, 'scale' => null]], - ['POINT_TEXT', ['name' => Literal::from('point'), 'limit' => null, 'scale' => null]], - ['POLYGON_TEXT', ['name' => Literal::from('polygon'), 'limit' => null, 'scale' => null]], - ['LINESTRING_TEXT', ['name' => Literal::from('linestring'), 'limit' => null, 'scale' => null]], - ['GEOMETRY_TEXT', ['name' => Literal::from('geometry'), 'limit' => null, 'scale' => null]], - ['BIT_TEXT', ['name' => Literal::from('bit'), 'limit' => null, 'scale' => null]], - ['ENUM_TEXT', ['name' => Literal::from('enum'), 'limit' => null, 'scale' => null]], - ['SET_TEXT', ['name' => Literal::from('set'), 'limit' => null, 'scale' => null]], - ['CIDR_TEXT', ['name' => Literal::from('cidr'), 'limit' => null, 'scale' => null]], - ['INET_TEXT', ['name' => Literal::from('inet'), 'limit' => null, 'scale' => null]], - ['MACADDR_TEXT', ['name' => Literal::from('macaddr'), 'limit' => null, 'scale' => null]], - ['INTERVAL_TEXT', ['name' => Literal::from('interval'), 'limit' => null, 'scale' => null]], - ['FILESTREAM_TEXT', ['name' => Literal::from('filestream'), 'limit' => null, 'scale' => null]], - ['BIT_TEXT(2,12)', ['name' => Literal::from('bit'), 'limit' => 2, 'scale' => 12]], - ['not a type', ['name' => Literal::from('not a type'), 'limit' => null, 'scale' => null]], - ['NOT A TYPE', ['name' => Literal::from('NOT A TYPE'), 'limit' => null, 'scale' => null]], - ['not a type(2)', ['name' => Literal::from('not a type(2)'), 'limit' => null, 'scale' => null]], - ['NOT A TYPE(2)', ['name' => Literal::from('NOT A TYPE(2)'), 'limit' => null, 'scale' => null]], - ['ack', ['name' => Literal::from('ack'), 'limit' => null, 'scale' => null]], - ['ACK', ['name' => Literal::from('ACK'), 'limit' => null, 'scale' => null]], - ['ack_text', ['name' => Literal::from('ack_text'), 'limit' => null, 'scale' => null]], - ['ACK_TEXT', ['name' => Literal::from('ACK_TEXT'), 'limit' => null, 'scale' => null]], - ['ack_text(2,12)', ['name' => Literal::from('ack_text'), 'limit' => 2, 'scale' => 12]], - ['ACK_TEXT(12,2)', ['name' => Literal::from('ACK_TEXT'), 'limit' => 12, 'scale' => 2]], - [null, ['name' => null, 'limit' => null, 'scale' => null]], - ]; - } - - public function testGetColumnTypes() - { - $columnTypes = $this->adapter->getColumnTypes(); - $expected = [ - SqliteAdapter::PHINX_TYPE_BIG_INTEGER, - SqliteAdapter::PHINX_TYPE_BINARY, - SqliteAdapter::PHINX_TYPE_BLOB, - SqliteAdapter::PHINX_TYPE_BOOLEAN, - SqliteAdapter::PHINX_TYPE_CHAR, - SqliteAdapter::PHINX_TYPE_DATE, - SqliteAdapter::PHINX_TYPE_DATETIME, - SqliteAdapter::PHINX_TYPE_DECIMAL, - SqliteAdapter::PHINX_TYPE_DOUBLE, - SqliteAdapter::PHINX_TYPE_FLOAT, - SqliteAdapter::PHINX_TYPE_INTEGER, - SqliteAdapter::PHINX_TYPE_JSON, - SqliteAdapter::PHINX_TYPE_JSONB, - SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, - SqliteAdapter::PHINX_TYPE_STRING, - SqliteAdapter::PHINX_TYPE_TEXT, - SqliteAdapter::PHINX_TYPE_TIME, - SqliteAdapter::PHINX_TYPE_UUID, - SqliteAdapter::PHINX_TYPE_BINARYUUID, - SqliteAdapter::PHINX_TYPE_TIMESTAMP, - SqliteAdapter::PHINX_TYPE_TINY_INTEGER, - SqliteAdapter::PHINX_TYPE_VARBINARY, - ]; - sort($columnTypes); - sort($expected); - - $this->assertEquals($expected, $columnTypes); - } - #[DataProvider('provideColumnTypesForValidation')] public function testIsValidColumnType($phinxType, $exp) { @@ -2952,39 +2732,30 @@ public function testIsValidColumnType($phinxType, $exp) public static function provideColumnTypesForValidation() { return [ - [SqliteAdapter::PHINX_TYPE_BIG_INTEGER, true], - [SqliteAdapter::PHINX_TYPE_BINARY, true], - [SqliteAdapter::PHINX_TYPE_BLOB, true], - [SqliteAdapter::PHINX_TYPE_BOOLEAN, true], - [SqliteAdapter::PHINX_TYPE_CHAR, true], - [SqliteAdapter::PHINX_TYPE_DATE, true], - [SqliteAdapter::PHINX_TYPE_DATETIME, true], - [SqliteAdapter::PHINX_TYPE_DOUBLE, true], - [SqliteAdapter::PHINX_TYPE_FLOAT, true], - [SqliteAdapter::PHINX_TYPE_INTEGER, true], - [SqliteAdapter::PHINX_TYPE_JSON, true], - [SqliteAdapter::PHINX_TYPE_JSONB, true], - [SqliteAdapter::PHINX_TYPE_SMALL_INTEGER, true], - [SqliteAdapter::PHINX_TYPE_STRING, true], - [SqliteAdapter::PHINX_TYPE_TEXT, true], - [SqliteAdapter::PHINX_TYPE_TIME, true], - [SqliteAdapter::PHINX_TYPE_UUID, true], - [SqliteAdapter::PHINX_TYPE_TIMESTAMP, true], - [SqliteAdapter::PHINX_TYPE_VARBINARY, true], - [SqliteAdapter::PHINX_TYPE_BIT, false], - [SqliteAdapter::PHINX_TYPE_CIDR, false], - [SqliteAdapter::PHINX_TYPE_DECIMAL, true], - [SqliteAdapter::PHINX_TYPE_ENUM, false], - [SqliteAdapter::PHINX_TYPE_FILESTREAM, false], - [SqliteAdapter::PHINX_TYPE_GEOMETRY, false], - [SqliteAdapter::PHINX_TYPE_INET, false], - [SqliteAdapter::PHINX_TYPE_INTERVAL, false], - [SqliteAdapter::PHINX_TYPE_LINESTRING, false], - [SqliteAdapter::PHINX_TYPE_MACADDR, false], - [SqliteAdapter::PHINX_TYPE_POINT, false], - [SqliteAdapter::PHINX_TYPE_POLYGON, false], - [SqliteAdapter::PHINX_TYPE_SET, false], - [Literal::from('someType'), true], + [SqliteAdapter::TYPE_BIGINTEGER, true], + [SqliteAdapter::TYPE_BINARY, true], + [SqliteAdapter::TYPE_BOOLEAN, true], + [SqliteAdapter::TYPE_CHAR, true], + [SqliteAdapter::TYPE_DATE, true], + [SqliteAdapter::TYPE_DATETIME, true], + [SqliteAdapter::TYPE_FLOAT, true], + [SqliteAdapter::TYPE_INTEGER, true], + [SqliteAdapter::TYPE_JSON, true], + [SqliteAdapter::TYPE_SMALLINTEGER, true], + [SqliteAdapter::TYPE_STRING, true], + [SqliteAdapter::TYPE_TEXT, true], + [SqliteAdapter::TYPE_TIME, true], + [SqliteAdapter::TYPE_UUID, true], + [SqliteAdapter::TYPE_TIMESTAMP, true], + [SqliteAdapter::TYPE_CIDR, false], + [SqliteAdapter::TYPE_DECIMAL, true], + [SqliteAdapter::TYPE_GEOMETRY, false], + [SqliteAdapter::TYPE_INET, false], + [SqliteAdapter::TYPE_INTERVAL, false], + [SqliteAdapter::TYPE_LINESTRING, false], + [SqliteAdapter::TYPE_MACADDR, false], + [SqliteAdapter::TYPE_POINT, false], + [SqliteAdapter::TYPE_POLYGON, false], ['someType', false], ]; } @@ -3030,12 +2801,13 @@ public static function provideColumnNamesToCheck() { return [ ['create table t(a text)', 'a', true], - ['create table t(A text)', 'a', true], + ['create table t(A text)', 'a', false], + ['create table t(A text)', 'A', true], ['create table t("a" text)', 'a', true], ['create table t([a] text)', 'a', true], ['create table t(\'a\' text)', 'a', true], - ['create table t("A" text)', 'a', true], - ['create table t(a text)', 'A', true], + ['create table t("A" text)', 'A', true], + ['create table t(a text)', 'a', true], ['create table t(b text)', 'a', false], ['create table t(b text, a text)', 'a', true], ['create table t("0" text)', '0', true], @@ -3381,4 +3153,244 @@ public function testPdoExceptionUpdateNonExistingTable() $table = new Table('non_existing_table', [], $this->adapter); $table->addColumn('column', 'string')->update(); } + + public function testAddCheckConstraint() + { + $table = new Table('check_table', [], $this->adapter); + $table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->create(); + + $checkConstraint = new CheckConstraint('price_positive', 'price > 0'); + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); + + $this->assertTrue($this->adapter->hasCheckConstraint('check_table', 'price_positive')); + } + + public function testHasCheckConstraint() + { + $table = new Table('check_table3', [], $this->adapter); + $table->addColumn('quantity', 'integer') + ->create(); + + $checkConstraint = new CheckConstraint('quantity_positive', 'quantity > 0'); + $this->assertFalse($this->adapter->hasCheckConstraint('check_table3', 'quantity_positive')); + + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); + + $this->assertTrue($this->adapter->hasCheckConstraint('check_table3', 'quantity_positive')); + } + + public function testDropCheckConstraint() + { + $table = new Table('check_table4', [], $this->adapter); + $table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->create(); + + $checkConstraint = new CheckConstraint('price_check', 'price BETWEEN 0 AND 1000'); + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); + $this->assertTrue($this->adapter->hasCheckConstraint('check_table4', 'price_check')); + + $this->adapter->dropCheckConstraint('check_table4', 'price_check'); + $this->assertFalse($this->adapter->hasCheckConstraint('check_table4', 'price_check')); + } + + public function testCheckConstraintWithComplexExpression() + { + $table = new Table('check_table5', [], $this->adapter); + $table->addColumn('email', 'string', ['limit' => 255]) + ->addColumn('status', 'string', ['limit' => 20]) + ->create(); + + $checkConstraint = new CheckConstraint( + 'status_valid', + "status IN ('active', 'inactive', 'pending')", + ); + $this->adapter->addCheckConstraint($table->getTable(), $checkConstraint); + $this->assertTrue($this->adapter->hasCheckConstraint('check_table5', 'status_valid')); + + // Verify the constraint is actually enforced + $quotedTableName = $this->adapter->getConnection()->getDriver()->quoteIdentifier('check_table5'); + $this->expectException(PDOException::class); + $this->adapter->execute("INSERT INTO {$quotedTableName} (email, status) VALUES ('test@example.com', 'invalid')"); + } + + public function testInsertOrSkipWithDuplicates() + { + $table = new Table('users', [], $this->adapter); + $table->addColumn('email', 'string', ['limit' => 255]) + ->addColumn('name', 'string') + ->addIndex('email', ['unique' => true]) + ->create(); + + // First insert + $table->insertOrSkip([ + ['email' => 'test@example.com', 'name' => 'John'], + ])->save(); + + // Duplicate - should be skipped + $table->insertOrSkip([ + ['email' => 'test@example.com', 'name' => 'Jane'], + ])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM users'); + $this->assertCount(1, $rows); + $this->assertEquals('John', $rows[0]['name']); + } + + public function testInsertModeResetsAfterInsertOrSkip() + { + $table = new Table('users', [], $this->adapter); + $table->addColumn('email', 'string', ['limit' => 255]) + ->addColumn('name', 'string') + ->addIndex('email', ['unique' => true]) + ->create(); + + // First insert with insertOrSkip + $table->insertOrSkip([ + ['email' => 'test@example.com', 'name' => 'John'], + ])->save(); + + // Now use regular insert with duplicate - should throw exception + $this->expectException(PDOException::class); + $table->insert([ + ['email' => 'test@example.com', 'name' => 'Jane'], + ])->save(); + } + + public function testBulkinsertOrSkipWithDuplicates() + { + $table = new Table('products', [], $this->adapter); + $table->addColumn('sku', 'string', ['limit' => 50]) + ->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->addIndex('sku', ['unique' => true]) + ->create(); + + // First bulk insert + $table->insertOrSkip([ + ['sku' => 'ABC123', 'price' => 10.50], + ['sku' => 'DEF456', 'price' => 20.00], + ])->save(); + + // Mix of new and duplicate - duplicates should be skipped + $table->insertOrSkip([ + ['sku' => 'ABC123', 'price' => 99.99], // Duplicate + ['sku' => 'GHI789', 'price' => 30.00], // New + ])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM products ORDER BY sku'); + $this->assertCount(3, $rows); + $this->assertEquals('10.50', $rows[0]['price']); // Original price preserved + $this->assertEquals('20.00', $rows[1]['price']); + $this->assertEquals('30.00', $rows[2]['price']); + } + + public function testInsertOrSkipWithoutDuplicates() + { + $table = new Table('categories', [], $this->adapter); + $table->addColumn('name', 'string') + ->create(); + + // Should work like normal insert + $table->insertOrSkip([ + ['name' => 'Category 1'], + ['name' => 'Category 2'], + ])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM categories'); + $this->assertCount(2, $rows); + } + + public function testInsertOrUpdateWithDuplicates() + { + $table = new Table('currencies', [], $this->adapter); + $table->addColumn('code', 'string', ['limit' => 3]) + ->addColumn('rate', 'decimal', ['precision' => 10, 'scale' => 4]) + ->addIndex('code', ['unique' => true]) + ->create(); + + // First insert + $table->insertOrUpdate([ + ['code' => 'USD', 'rate' => 1.0000], + ['code' => 'EUR', 'rate' => 0.9000], + ], ['rate'], ['code'])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM currencies ORDER BY code'); + $this->assertCount(2, $rows); + $this->assertEquals('0.9000', $rows[0]['rate']); // EUR + $this->assertEquals('1.0000', $rows[1]['rate']); // USD + + // Update rates - should update existing rows + $table->insertOrUpdate([ + ['code' => 'USD', 'rate' => 1.0500], + ['code' => 'EUR', 'rate' => 0.9234], + ['code' => 'GBP', 'rate' => 0.7800], // New row + ], ['rate'], ['code'])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM currencies ORDER BY code'); + $this->assertCount(3, $rows); + $this->assertEquals('0.9234', $rows[0]['rate']); // EUR updated + $this->assertEquals('0.7800', $rows[1]['rate']); // GBP new + $this->assertEquals('1.0500', $rows[2]['rate']); // USD updated + } + + public function testInsertOrUpdateWithMultipleUpdateColumns() + { + $table = new Table('products', [], $this->adapter); + $table->addColumn('sku', 'string', ['limit' => 50]) + ->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) + ->addColumn('stock', 'integer') + ->addIndex('sku', ['unique' => true]) + ->create(); + + // First insert + $table->insertOrUpdate([ + ['sku' => 'ABC123', 'price' => 10.00, 'stock' => 100], + ], ['price', 'stock'], ['sku'])->save(); + + // Update both price and stock + $table->insertOrUpdate([ + ['sku' => 'ABC123', 'price' => 15.00, 'stock' => 50], + ], ['price', 'stock'], ['sku'])->save(); + + $rows = $this->adapter->fetchAll('SELECT * FROM products'); + $this->assertCount(1, $rows); + $this->assertEquals('15.00', $rows[0]['price']); + $this->assertEquals(50, $rows[0]['stock']); + } + + public function testInsertOrUpdateModeResetsAfterSave() + { + $table = new Table('items', [], $this->adapter); + $table->addColumn('code', 'string', ['limit' => 10]) + ->addColumn('name', 'string') + ->addIndex('code', ['unique' => true]) + ->create(); + + // Use insertOrUpdate + $table->insertOrUpdate([ + ['code' => 'ITEM1', 'name' => 'Item One'], + ], ['name'], ['code'])->save(); + + // Now use regular insert with duplicate - should throw exception + $this->expectException(PDOException::class); + $table->insert([ + ['code' => 'ITEM1', 'name' => 'Different Name'], + ])->save(); + } + + public function testInsertOrUpdateRequiresConflictColumns() + { + $table = new Table('currencies', [], $this->adapter); + $table->addColumn('code', 'string', ['limit' => 3]) + ->addColumn('rate', 'decimal', ['precision' => 10, 'scale' => 4]) + ->addIndex('code', ['unique' => true]) + ->create(); + + // SQLite requires conflictColumns for insertOrUpdate + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('SQLite requires the $conflictColumns parameter'); + $table->insertOrUpdate([ + ['code' => 'USD', 'rate' => 1.0000], + ], ['rate'], [])->save(); + } } diff --git a/tests/TestCase/Db/Adapter/SqlserverAdapterTest.php b/tests/TestCase/Db/Adapter/SqlserverAdapterTest.php index f5e5a01ae..f494d45d2 100644 --- a/tests/TestCase/Db/Adapter/SqlserverAdapterTest.php +++ b/tests/TestCase/Db/Adapter/SqlserverAdapterTest.php @@ -19,7 +19,6 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\TestCase; -use RuntimeException; class SqlserverAdapterTest extends TestCase { @@ -230,7 +229,7 @@ public function testCreateTableWithMultiplePrimaryKeys() ->addColumn('tag_id', 'integer', ['null' => false]) ->save(); $this->assertTrue($this->adapter->hasIndex('table1', ['user_id', 'tag_id'])); - $this->assertTrue($this->adapter->hasIndex('table1', ['tag_id', 'USER_ID'])); + $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'USER_ID'])); $this->assertFalse($this->adapter->hasIndex('table1', ['tag_id', 'user_email'])); } @@ -475,10 +474,10 @@ public function testAddColumnWithNotNullableNoDefault() $columns = $this->adapter->getColumns('table1'); $this->assertCount(2, $columns); - $this->assertArrayHasKey('id', $columns); - $this->assertArrayHasKey('col', $columns); - $this->assertFalse($columns['col']->isNull()); - $this->assertNull($columns['col']->getDefault()); + $this->assertEquals('id', $columns[0]->getName()); + $this->assertEquals('col', $columns[1]->getName()); + $this->assertFalse($columns[1]->isNull()); + $this->assertNull($columns[1]->getDefault()); } public function testAddColumnWithDefaultBool() @@ -578,7 +577,7 @@ public function testChangeColumnDefaults() $this->assertTrue($this->adapter->hasColumn('t', 'column1')); $columns = $this->adapter->getColumns('t'); - $this->assertSame('test', $columns['column1']->getDefault()); + $this->assertSame('test', $columns[1]->getDefault()); $newColumn1 = new Column(); $newColumn1 @@ -589,7 +588,7 @@ public function testChangeColumnDefaults() $this->assertTrue($this->adapter->hasColumn('t', 'column1')); $columns = $this->adapter->getColumns('t'); - $this->assertSame('another test', $columns['column1']->getDefault()); + $this->assertSame('another test', $columns[1]->getDefault()); } public function testChangeColumnDefaultToNull() @@ -604,7 +603,7 @@ public function testChangeColumnDefaultToNull() ->setDefault(null); $table->changeColumn('column1', $newColumn1)->save(); $columns = $this->adapter->getColumns('t'); - $this->assertNull($columns['column1']->getDefault()); + $this->assertNull($columns[1]->getDefault()); } public function testChangeColumnDefaultToZero() @@ -619,7 +618,7 @@ public function testChangeColumnDefaultToZero() ->setDefault(0); $table->changeColumn('column1', $newColumn1)->save(); $columns = $this->adapter->getColumns('t'); - $this->assertSame(0, $columns['column1']->getDefault()); + $this->assertSame(0, $columns[1]->getDefault()); } public function testDropColumn() @@ -665,11 +664,11 @@ public function testGetColumns($colName, $type, $options, $actualType = null) $columns = $this->adapter->getColumns('t'); $this->assertCount(2, $columns); - $this->assertEquals('id', $columns['id']->getName()); - $this->assertTrue($columns['id']->getIdentity()); + $this->assertEquals('id', $columns[0]->getName()); + $this->assertTrue($columns[0]->getIdentity()); - $this->assertEquals($colName, $columns[$colName]->getName()); - $this->assertEquals($actualType ?? $type, $columns[$colName]->getType()); + $this->assertEquals($colName, $columns[1]->getName()); + $this->assertEquals($actualType ?? $type, $columns[1]->getType()); } public function testAddIndex() @@ -718,10 +717,10 @@ public function testAddIndexWithIncludeColumns() ->addColumn('firstname', 'string') ->addColumn('lastname', 'string') ->save(); - $this->assertFalse($table->hasIndex('email')); + $this->assertFalse($table->hasIndex(['email'])); $table->addIndex(['email'], ['include' => ['firstname', 'lastname']]) ->save(); - $this->assertTrue($table->hasIndex('email')); + $this->assertTrue($table->hasIndex(['email'])); $rows = $this->adapter->fetchAll("SELECT ic.is_included_column AS included FROM sys.indexes AS i INNER JOIN sys.index_columns AS ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id @@ -848,7 +847,7 @@ public function testAddForeignKey() $table->addColumn('ref_table_id', 'integer')->save(); $fk = new ForeignKey(); - $fk->setReferencedTable($refTable->getTable()) + $fk->setReferencedTable($refTable->getTable()->getName()) ->setColumns(['ref_table_id']) ->setReferencedColumns(['id']) ->setName('fk1'); @@ -1069,6 +1068,7 @@ public static function provideForeignKeysToCheck() ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['b', 'a'], false], ['create table t(a int, [B] int, foreign key(a,[B]) references other(a,b))', ['a', 'b'], false], ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['a', 'B'], false], + ['create table t(a int, b int, foreign key(a,b) references other(a,b))', ['a', 'b'], true], ['create table t(a int, b int, c int, foreign key(a,b,c) references other(a,b,c))', ['a', 'b'], false], ['create table t(a int, foreign key(a) references other(a))', ['a', 'b'], false], ['create table t(a int, b int, foreign key(a) references other(a), foreign key(b) references other(b))', ['a', 'b'], false], @@ -1149,44 +1149,6 @@ public function testQuoteSchemaName() $this->assertEquals('[schema].[schema]', $this->adapter->quoteSchemaName('schema.schema')); } - public function testInvalidSqlType() - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Column type "idontexist" is not supported by SqlServer.'); - - $this->adapter->getSqlType('idontexist'); - } - - public function testGetPhinxType() - { - $this->assertEquals('integer', $this->adapter->getPhinxType('int')); - $this->assertEquals('integer', $this->adapter->getPhinxType('integer')); - - $this->assertEquals('tinyinteger', $this->adapter->getPhinxType('tinyint')); - $this->assertEquals('smallinteger', $this->adapter->getPhinxType('smallint')); - $this->assertEquals('biginteger', $this->adapter->getPhinxType('bigint')); - - $this->assertEquals('decimal', $this->adapter->getPhinxType('decimal')); - $this->assertEquals('decimal', $this->adapter->getPhinxType('numeric')); - - $this->assertEquals('float', $this->adapter->getPhinxType('real')); - - $this->assertEquals('boolean', $this->adapter->getPhinxType('bit')); - - $this->assertEquals('string', $this->adapter->getPhinxType('varchar')); - $this->assertEquals('string', $this->adapter->getPhinxType('nvarchar')); - $this->assertEquals('char', $this->adapter->getPhinxType('char')); - $this->assertEquals('char', $this->adapter->getPhinxType('nchar')); - - $this->assertEquals('text', $this->adapter->getPhinxType('text')); - - $this->assertEquals('datetime', $this->adapter->getPhinxType('timestamp')); - - $this->assertEquals('date', $this->adapter->getPhinxType('date')); - - $this->assertEquals('datetime', $this->adapter->getPhinxType('datetime')); - } - public function testAddColumnComment() { $table = new Table('table1', [], $this->adapter); @@ -1537,4 +1499,19 @@ public function testIdentityInsert() $this->assertEquals(20, $res[0]['id']); $this->assertEquals(50, $res[1]['id']); } + + public function testInsertOrSkipThrowsException() + { + $table = new Table('users', [], $this->adapter); + $table->addColumn('email', 'string', ['limit' => 255]) + ->addColumn('name', 'string') + ->create(); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('INSERT IGNORE is not supported for SQL Server'); + + $table->insertOrSkip([ + ['email' => 'test@example.com', 'name' => 'John'], + ])->save(); + } } diff --git a/tests/TestCase/Db/Adapter/UnifiedMigrationsTableStorageTest.php b/tests/TestCase/Db/Adapter/UnifiedMigrationsTableStorageTest.php new file mode 100644 index 000000000..d7b30fc83 --- /dev/null +++ b/tests/TestCase/Db/Adapter/UnifiedMigrationsTableStorageTest.php @@ -0,0 +1,229 @@ +cleanupTable(); + } + + public function tearDown(): void + { + // Always clean up the table + $this->cleanupTable(); + + Configure::delete('Migrations.legacyTables'); + + parent::tearDown(); + } + + /** + * Clean up the unified migrations table and other test artifacts. + */ + private function cleanupTable(): void + { + try { + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $driver = $connection->getDriver(); + + // Drop unified migrations table + $connection->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $driver->quoteIdentifier(UnifiedMigrationsTableStorage::TABLE_NAME), + )); + + // Drop tables created by test migrations + $connection->execute('DROP TABLE IF EXISTS migrator'); + $connection->execute('DROP TABLE IF EXISTS numbers'); + $connection->execute('DROP TABLE IF EXISTS letters'); + $connection->execute('DROP TABLE IF EXISTS stores'); + $connection->execute('DROP TABLE IF EXISTS mark_migrated'); + $connection->execute('DROP TABLE IF EXISTS mark_migrated_test'); + + // Also drop any phinxlog tables that might exist + $connection->execute('DROP TABLE IF EXISTS phinxlog'); + $connection->execute('DROP TABLE IF EXISTS migrator_phinxlog'); + } catch (Exception $e) { + // Ignore cleanup errors + } + } + + public function testTableName(): void + { + $this->assertSame('cake_migrations', UnifiedMigrationsTableStorage::TABLE_NAME); + } + + public function testMigrateCreatesUnifiedTable(): void + { + // Run a migration which should create the unified table + $this->exec('migrations migrate -c test --source Migrations --no-lock'); + $this->assertExitSuccess(); + + // Verify unified table was created + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $dialect = $connection->getDriver()->schemaDialect(); + + $this->assertTrue($dialect->hasTable(UnifiedMigrationsTableStorage::TABLE_NAME)); + $this->tableCreated = true; + + // Verify records were inserted with null plugin (app migrations) + $result = $connection->selectQuery() + ->select('*') + ->from(UnifiedMigrationsTableStorage::TABLE_NAME) + ->execute() + ->fetchAll('assoc'); + + $this->assertGreaterThan(0, count($result)); + + // All records should have null plugin (app migrations) + foreach ($result as $row) { + $this->assertNull($row['plugin']); + } + } + + public function testMigratePluginUsesUnifiedTable(): void + { + $this->loadPlugins(['Migrator']); + + // Run app migrations first to create the table + $this->exec('migrations migrate -c test --source Migrations --no-lock'); + $this->assertExitSuccess(); + $this->tableCreated = true; + + // Clear the migration records for app (but keep the table) + $this->clearMigrationRecords('test'); + + // Run plugin migrations + $this->exec('migrations migrate -c test --plugin Migrator --no-lock'); + $this->assertExitSuccess(); + + // Verify plugin records were inserted with plugin name + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $result = $connection->selectQuery() + ->select('*') + ->from(UnifiedMigrationsTableStorage::TABLE_NAME) + ->where(['plugin' => 'Migrator']) + ->execute() + ->fetchAll('assoc'); + + $this->assertGreaterThan(0, count($result)); + $this->assertEquals('Migrator', $result[0]['plugin']); + } + + public function testRollbackWithUnifiedTable(): void + { + // Run migrations + $this->exec('migrations migrate -c test --source Migrations --no-lock'); + $this->assertExitSuccess(); + $this->tableCreated = true; + + // Verify we have records + $initialCount = $this->getMigrationRecordCount('test'); + $this->assertGreaterThan(0, $initialCount); + + // Rollback + $this->exec('migrations rollback -c test --source Migrations --no-lock'); + $this->assertExitSuccess(); + + // Verify record was removed + $afterCount = $this->getMigrationRecordCount('test'); + $this->assertLessThan($initialCount, $afterCount); + } + + public function testStatusWithUnifiedTable(): void + { + // Run migrations + $this->exec('migrations migrate -c test --source Migrations --no-lock'); + $this->assertExitSuccess(); + $this->tableCreated = true; + + // Check status + $this->exec('migrations status -c test --source Migrations'); + $this->assertExitSuccess(); + $this->assertOutputContains('up'); + } + + public function testAppAndPluginMigrationsAreSeparated(): void + { + $this->loadPlugins(['Migrator']); + + // Run app migrations + $this->exec('migrations migrate -c test --source Migrations --no-lock'); + $this->assertExitSuccess(); + $this->tableCreated = true; + + // Run plugin migrations + $this->exec('migrations migrate -c test --plugin Migrator --no-lock'); + $this->assertExitSuccess(); + + // Verify both app and plugin records exist in same table but are separated + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + // App records (plugin IS NULL) + $appCount = $connection->selectQuery() + ->select(['count' => $connection->selectQuery()->func()->count('*')]) + ->from(UnifiedMigrationsTableStorage::TABLE_NAME) + ->where(['plugin IS' => null]) + ->execute() + ->fetch('assoc'); + + // Plugin records + $pluginCount = $connection->selectQuery() + ->select(['count' => $connection->selectQuery()->func()->count('*')]) + ->from(UnifiedMigrationsTableStorage::TABLE_NAME) + ->where(['plugin' => 'Migrator']) + ->execute() + ->fetch('assoc'); + + $this->assertGreaterThan(0, (int)$appCount['count'], 'App migrations should exist'); + $this->assertGreaterThan(0, (int)$pluginCount['count'], 'Plugin migrations should exist'); + + // Rolling back app shouldn't affect plugin + $this->exec('migrations rollback -c test --source Migrations --target 0 --no-lock'); + $this->assertExitSuccess(); + + // Plugin migrations should still exist + $pluginCountAfter = $connection->selectQuery() + ->select(['count' => $connection->selectQuery()->func()->count('*')]) + ->from(UnifiedMigrationsTableStorage::TABLE_NAME) + ->where(['plugin' => 'Migrator']) + ->execute() + ->fetch('assoc'); + + $this->assertEquals($pluginCount['count'], $pluginCountAfter['count'], 'Plugin migrations should be unaffected'); + } +} diff --git a/tests/TestCase/Db/Table/ColumnTest.php b/tests/TestCase/Db/Table/ColumnTest.php index b333ee7f3..0652149f3 100644 --- a/tests/TestCase/Db/Table/ColumnTest.php +++ b/tests/TestCase/Db/Table/ColumnTest.php @@ -8,12 +8,36 @@ use Cake\Database\ValueBinder; use Migrations\Db\Literal; use Migrations\Db\Table\Column; -use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use RuntimeException; class ColumnTest extends TestCase { + protected function tearDown(): void + { + parent::tearDown(); + // Restore bootstrap defaults + Configure::write('Migrations.unsigned_primary_keys', true); + Configure::write('Migrations.column_null_default', true); + Configure::delete('Migrations.unsigned_ints'); + } + + public function testNullConstructorParameter() + { + $column = new Column(name: 'title'); + $this->assertTrue($column->isNull()); + + $column = new Column(name: 'title', null: true); + $this->assertTrue($column->isNull()); + + $column = new Column(name: 'title', null: false); + $this->assertFalse($column->isNull()); + + Configure::write('Migrations.column_null_default', true); + $column = new Column(name: 'title'); + $this->assertTrue($column->isNull()); + } + public function testSetOptionThrowsExceptionIfOptionIsNotString() { $column = new Column(); @@ -35,7 +59,6 @@ public function testSetOptionsIdentity() $this->assertTrue($column->isIdentity()); } - #[RunInSeparateProcess] public function testColumnNullFeatureFlag() { $column = new Column(); @@ -56,4 +79,200 @@ public function testToArrayDefaultLiteralValue(): void $this->assertInstanceOf(QueryExpression::class, $result['default']); $this->assertEquals('CURRENT_TIMESTAMP', $result['default']->sql(new ValueBinder())); } + + public function testIntegerColumnDefaultsToSigned(): void + { + $column = new Column(); + $column->setName('user_id')->setType('integer'); + + $this->assertFalse($column->isUnsigned()); + $this->assertTrue($column->isSigned()); + $this->assertFalse($column->getUnsigned()); + } + + public function testBigIntegerColumnDefaultsToSigned(): void + { + $column = new Column(); + $column->setName('big_id')->setType('biginteger'); + + $this->assertFalse($column->isUnsigned()); + $this->assertTrue($column->isSigned()); + $this->assertFalse($column->getUnsigned()); + } + + public function testSmallIntegerColumnDefaultsToSigned(): void + { + $column = new Column(); + $column->setName('small_id')->setType('smallinteger'); + + $this->assertFalse($column->isUnsigned()); + $this->assertTrue($column->isSigned()); + $this->assertFalse($column->getUnsigned()); + } + + public function testTinyIntegerColumnDefaultsToSigned(): void + { + $column = new Column(); + $column->setName('tiny_id')->setType('tinyinteger'); + + $this->assertFalse($column->isUnsigned()); + $this->assertTrue($column->isSigned()); + $this->assertFalse($column->getUnsigned()); + } + + public function testNonIntegerColumnDoesNotDefaultToUnsigned(): void + { + $stringColumn = new Column(); + $stringColumn->setName('name')->setType('string'); + $this->assertFalse($stringColumn->getUnsigned()); + $this->assertFalse($stringColumn->isUnsigned()); + + $dateColumn = new Column(); + $dateColumn->setName('created')->setType('datetime'); + $this->assertFalse($dateColumn->getUnsigned()); + $this->assertFalse($dateColumn->isUnsigned()); + + $decimalColumn = new Column(); + $decimalColumn->setName('price')->setType('decimal'); + $this->assertFalse($decimalColumn->getUnsigned()); + $this->assertFalse($decimalColumn->isUnsigned()); + } + + public function testExplicitSignedOverridesDefault(): void + { + $column = new Column(); + $column->setName('counter')->setType('integer')->setSigned(true); + + $this->assertFalse($column->isUnsigned()); + $this->assertTrue($column->isSigned()); + $this->assertFalse($column->getUnsigned()); + } + + public function testExplicitUnsignedIsPreserved(): void + { + $column = new Column(); + $column->setName('age')->setType('integer')->setUnsigned(true); + + $this->assertTrue($column->isUnsigned()); + $this->assertFalse($column->isSigned()); + $this->assertTrue($column->getUnsigned()); + } + + public function testToArrayReturnsFalseForIntegersByDefault(): void + { + $column = new Column(); + $column->setName('user_id')->setType('integer'); + + $result = $column->toArray(); + // getUnsigned() returns false for integer types by default (signed) + $this->assertFalse($result['unsigned']); + } + + public function testToArrayReturnsFalseForNonIntegerTypes(): void + { + $column = new Column(); + $column->setName('title')->setType('string'); + + $result = $column->toArray(); + $this->assertFalse($result['unsigned']); + } + + public function testToArrayRespectsExplicitSigned(): void + { + $column = new Column(); + $column->setName('offset')->setType('integer')->setSigned(true); + + $result = $column->toArray(); + $this->assertFalse($result['unsigned']); + } + + public function testUnsignedIntsConfiguration(): void + { + // Without configuration, integers default to signed + Configure::delete('Migrations.unsigned_ints'); + $column = new Column(); + $column->setName('count')->setType('integer'); + $this->assertFalse($column->isUnsigned()); + $this->assertTrue($column->isSigned()); + + // With configuration enabled, integers default to unsigned + Configure::write('Migrations.unsigned_ints', true); + $column = new Column(); + $column->setName('count')->setType('integer'); + $this->assertTrue($column->isUnsigned()); + $this->assertFalse($column->isSigned()); + + // Explicit signed overrides configuration + $column = new Column(); + $column->setName('offset')->setType('integer')->setSigned(true); + $this->assertFalse($column->isUnsigned()); + $this->assertTrue($column->isSigned()); + } + + public function testUnsignedPrimaryKeysConfiguration(): void + { + // Without configuration, identity columns default to signed + Configure::delete('Migrations.unsigned_primary_keys'); + $column = new Column(); + $column->setName('id')->setType('integer')->setIdentity(true); + $this->assertFalse($column->isUnsigned()); + $this->assertTrue($column->isSigned()); + + // With configuration enabled, identity columns default to unsigned + Configure::write('Migrations.unsigned_primary_keys', true); + $column = new Column(); + $column->setName('id')->setType('integer')->setIdentity(true); + $this->assertTrue($column->isUnsigned()); + $this->assertFalse($column->isSigned()); + + // Non-identity columns are not affected by unsigned_primary_keys + $column = new Column(); + $column->setName('user_id')->setType('integer'); + $this->assertFalse($column->isUnsigned()); + + // Explicit signed overrides configuration + $column = new Column(); + $column->setName('id')->setType('integer')->setIdentity(true)->setSigned(true); + $this->assertFalse($column->isUnsigned()); + $this->assertTrue($column->isSigned()); + } + + public function testBothUnsignedConfigurationsWork(): void + { + Configure::write('Migrations.unsigned_primary_keys', true); + Configure::write('Migrations.unsigned_ints', true); + + // Identity columns use unsigned_primary_keys configuration + $identityColumn = new Column(); + $identityColumn->setName('id')->setType('integer')->setIdentity(true); + $this->assertTrue($identityColumn->isUnsigned()); + + // Regular integer columns use unsigned_ints configuration + $intColumn = new Column(); + $intColumn->setName('count')->setType('integer'); + $this->assertTrue($intColumn->isUnsigned()); + + // Non-integer columns are not affected + $stringColumn = new Column(); + $stringColumn->setName('name')->setType('string'); + $this->assertFalse($stringColumn->isUnsigned()); + } + + public function testUnsignedConfigurationDoesNotAffectNonIntegerTypes(): void + { + Configure::write('Migrations.unsigned_ints', true); + Configure::write('Migrations.unsigned_primary_keys', true); + + $stringColumn = new Column(); + $stringColumn->setName('name')->setType('string'); + $this->assertFalse($stringColumn->isUnsigned()); + + $dateColumn = new Column(); + $dateColumn->setName('created')->setType('datetime'); + $this->assertFalse($dateColumn->isUnsigned()); + + $decimalColumn = new Column(); + $decimalColumn->setName('price')->setType('decimal'); + $this->assertFalse($decimalColumn->isUnsigned()); + } } diff --git a/tests/TestCase/Db/Table/ForeignKeyTest.php b/tests/TestCase/Db/Table/ForeignKeyTest.php index c4a80398d..c2fce98ab 100644 --- a/tests/TestCase/Db/Table/ForeignKeyTest.php +++ b/tests/TestCase/Db/Table/ForeignKeyTest.php @@ -1,7 +1,7 @@ assertNull($this->fk->getName()); + $this->assertSame('', $this->fk->getName()); $this->assertSame($this->fk, $this->fk->setName('fk_name')); $this->assertEquals('fk_name', $this->fk->getName()); } @@ -48,8 +48,8 @@ public function testOnDeleteSetNullCanBeSetThroughOptions() public function testInitiallyActionsEmpty() { - $this->assertNull($this->fk->getOnDelete()); - $this->assertNull($this->fk->getOnUpdate()); + $this->assertSame(ForeignKey::NO_ACTION, $this->fk->getOnDelete()); + $this->assertSame(ForeignKey::NO_ACTION, $this->fk->getOnUpdate()); } /** diff --git a/tests/TestCase/Db/Table/PartitionDefinitionTest.php b/tests/TestCase/Db/Table/PartitionDefinitionTest.php new file mode 100644 index 000000000..d4512cc79 --- /dev/null +++ b/tests/TestCase/Db/Table/PartitionDefinitionTest.php @@ -0,0 +1,103 @@ +assertSame('p2022', $definition->getName()); + } + + public function testGetValueNull(): void + { + $definition = new PartitionDefinition('p0'); + $this->assertNull($definition->getValue()); + } + + public function testGetValueString(): void + { + $definition = new PartitionDefinition('p2022', '2023-01-01'); + $this->assertSame('2023-01-01', $definition->getValue()); + } + + public function testGetValueInteger(): void + { + $definition = new PartitionDefinition('p0', 1000000); + $this->assertSame(1000000, $definition->getValue()); + } + + public function testGetValueArray(): void + { + $values = ['US', 'CA', 'MX']; + $definition = new PartitionDefinition('p_americas', $values); + $this->assertSame($values, $definition->getValue()); + } + + public function testGetValueMaxvalue(): void + { + $definition = new PartitionDefinition('pmax', 'MAXVALUE'); + $this->assertSame('MAXVALUE', $definition->getValue()); + } + + public function testGetTablespace(): void + { + $definition = new PartitionDefinition('p2022', '2023-01-01', 'fast_storage'); + $this->assertSame('fast_storage', $definition->getTablespace()); + + $definition = new PartitionDefinition('p2022', '2023-01-01'); + $this->assertNull($definition->getTablespace()); + } + + public function testGetTable(): void + { + $definition = new PartitionDefinition('p2022', '2023-01-01', null, 'orders_archive_2022'); + $this->assertSame('orders_archive_2022', $definition->getTable()); + + $definition = new PartitionDefinition('p2022', '2023-01-01'); + $this->assertNull($definition->getTable()); + } + + public function testGetComment(): void + { + $definition = new PartitionDefinition('p2022', '2023-01-01', null, null, 'Archive partition for 2022'); + $this->assertSame('Archive partition for 2022', $definition->getComment()); + + $definition = new PartitionDefinition('p2022', '2023-01-01'); + $this->assertNull($definition->getComment()); + } + + public function testFullConstructor(): void + { + $definition = new PartitionDefinition( + 'p2022', + '2023-01-01', + 'fast_storage', + 'orders_2022', + 'Archive for 2022', + ); + + $this->assertSame('p2022', $definition->getName()); + $this->assertSame('2023-01-01', $definition->getValue()); + $this->assertSame('fast_storage', $definition->getTablespace()); + $this->assertSame('orders_2022', $definition->getTable()); + $this->assertSame('Archive for 2022', $definition->getComment()); + } + + public function testCompositeKeyValue(): void + { + $definition = new PartitionDefinition('p2023_east', [2023, 'east']); + $this->assertSame([2023, 'east'], $definition->getValue()); + } + + public function testRangeFromTo(): void + { + $definition = new PartitionDefinition('p2022', ['from' => '2022-01-01', 'to' => '2023-01-01']); + $this->assertSame(['from' => '2022-01-01', 'to' => '2023-01-01'], $definition->getValue()); + } +} diff --git a/tests/TestCase/Db/Table/PartitionTest.php b/tests/TestCase/Db/Table/PartitionTest.php new file mode 100644 index 000000000..bafc8bd78 --- /dev/null +++ b/tests/TestCase/Db/Table/PartitionTest.php @@ -0,0 +1,111 @@ +assertSame(Partition::TYPE_RANGE, $partition->getType()); + + $partition = new Partition(Partition::TYPE_LIST, 'region'); + $this->assertSame(Partition::TYPE_LIST, $partition->getType()); + + $partition = new Partition(Partition::TYPE_HASH, 'user_id'); + $this->assertSame(Partition::TYPE_HASH, $partition->getType()); + + $partition = new Partition(Partition::TYPE_KEY, 'cache_key'); + $this->assertSame(Partition::TYPE_KEY, $partition->getType()); + } + + public function testGetColumnsSingleColumn(): void + { + $partition = new Partition(Partition::TYPE_RANGE, 'created_at'); + $this->assertSame(['created_at'], $partition->getColumns()); + } + + public function testGetColumnsMultipleColumns(): void + { + $partition = new Partition(Partition::TYPE_RANGE, ['year', 'month']); + $this->assertSame(['year', 'month'], $partition->getColumns()); + } + + public function testGetColumnsWithLiteral(): void + { + $literal = Literal::from('YEAR(created_at)'); + $partition = new Partition(Partition::TYPE_RANGE, $literal); + $this->assertSame($literal, $partition->getColumns()); + } + + public function testGetCount(): void + { + $partition = new Partition(Partition::TYPE_HASH, 'user_id', [], 8); + $this->assertSame(8, $partition->getCount()); + + $partition = new Partition(Partition::TYPE_RANGE, 'created_at'); + $this->assertNull($partition->getCount()); + } + + public function testGetOptions(): void + { + $options = ['custom' => 'value']; + $partition = new Partition(Partition::TYPE_HASH, 'user_id', [], 8, $options); + $this->assertSame($options, $partition->getOptions()); + } + + public function testGetDefinitionsEmpty(): void + { + $partition = new Partition(Partition::TYPE_RANGE, 'created_at'); + $this->assertSame([], $partition->getDefinitions()); + } + + public function testGetDefinitionsWithInitialDefinitions(): void + { + $def1 = new PartitionDefinition('p2022', '2023-01-01'); + $def2 = new PartitionDefinition('p2023', '2024-01-01'); + $partition = new Partition(Partition::TYPE_RANGE, 'created_at', [$def1, $def2]); + + $definitions = $partition->getDefinitions(); + $this->assertCount(2, $definitions); + $this->assertSame($def1, $definitions[0]); + $this->assertSame($def2, $definitions[1]); + } + + public function testAddDefinition(): void + { + $partition = new Partition(Partition::TYPE_RANGE, 'created_at'); + $def = new PartitionDefinition('p2022', '2023-01-01'); + + $result = $partition->addDefinition($def); + + $this->assertSame($partition, $result); + $this->assertCount(1, $partition->getDefinitions()); + $this->assertSame($def, $partition->getDefinitions()[0]); + } + + public function testAddMultipleDefinitions(): void + { + $partition = new Partition(Partition::TYPE_RANGE, 'created_at'); + + $partition->addDefinition(new PartitionDefinition('p2022', '2023-01-01')) + ->addDefinition(new PartitionDefinition('p2023', '2024-01-01')) + ->addDefinition(new PartitionDefinition('pmax', 'MAXVALUE')); + + $this->assertCount(3, $partition->getDefinitions()); + } + + public function testTypeConstants(): void + { + $this->assertSame('RANGE', Partition::TYPE_RANGE); + $this->assertSame('LIST', Partition::TYPE_LIST); + $this->assertSame('HASH', Partition::TYPE_HASH); + $this->assertSame('KEY', Partition::TYPE_KEY); + } +} diff --git a/tests/TestCase/Db/Table/TableTest.php b/tests/TestCase/Db/Table/TableTest.php index 32461a448..21912fd58 100644 --- a/tests/TestCase/Db/Table/TableTest.php +++ b/tests/TestCase/Db/Table/TableTest.php @@ -129,7 +129,7 @@ public function testAddForeignKeyPositionalParameters(): void $actions = $this->getPendingActions($table); $this->assertInstanceOf(AddForeignKey::class, $actions[0]); $key = $actions[0]->getForeignKey(); - $this->assertSame($key->getReferencedTable()->getName(), 'users'); + $this->assertSame($key->getReferencedTable(), 'users'); $this->assertSame($key->getReferencedColumns(), ['id']); $this->assertSame($key->getColumns(), ['user_id']); $this->assertSame($key->getName(), 'fk_user_id'); @@ -152,48 +152,7 @@ public function testAddForeignKeyWithObject(): void $actions = $this->getPendingActions($table); $this->assertInstanceOf(AddForeignKey::class, $actions[0]); $key = $actions[0]->getForeignKey(); - $this->assertSame($key->getReferencedTable()->getName(), 'users'); - $this->assertSame($key->getReferencedColumns(), ['id']); - $this->assertSame($key->getColumns(), ['user_id']); - $this->assertSame($key->getName(), 'fk_user_id'); - } - - public function testAddForeignKeyWithNamePositionalParameters(): void - { - $adapter = new MysqlAdapter([]); - $table = new Table('ntable', [], $adapter); - $table->addForeignKeyWithName('fk_user_id', 'user_id', 'users', 'id', [ - 'delete' => 'CASCADE', - 'update' => 'CASCADE', - ]); - - $actions = $this->getPendingActions($table); - $this->assertInstanceOf(AddForeignKey::class, $actions[0]); - $key = $actions[0]->getForeignKey(); - $this->assertSame($key->getReferencedTable()->getName(), 'users'); - $this->assertSame($key->getReferencedColumns(), ['id']); - $this->assertSame($key->getColumns(), ['user_id']); - $this->assertSame($key->getName(), 'fk_user_id'); - } - - public function testAddForeignKeyWithNameObject(): void - { - $adapter = new MysqlAdapter([]); - $table = new Table('ntable', [], $adapter); - $key = new ForeignKey(); - $table->addForeignKeyWithName( - $key->setColumns('user_id') - ->setReferencedTable('users') - ->setReferencedColumns(['id']) - ->setOnDelete('CASCADE') - ->setOnUpdate('CASCADE') - ->setName('fk_user_id'), - ); - - $actions = $this->getPendingActions($table); - $this->assertInstanceOf(AddForeignKey::class, $actions[0]); - $key = $actions[0]->getForeignKey(); - $this->assertSame($key->getReferencedTable()->getName(), 'users'); + $this->assertSame($key->getReferencedTable(), 'users'); $this->assertSame($key->getReferencedColumns(), ['id']); $this->assertSame($key->getColumns(), ['user_id']); $this->assertSame($key->getName(), 'fk_user_id'); diff --git a/tests/TestCase/Migration/EnvironmentTest.php b/tests/TestCase/Migration/EnvironmentTest.php index 2a3aabd3a..84d060582 100644 --- a/tests/TestCase/Migration/EnvironmentTest.php +++ b/tests/TestCase/Migration/EnvironmentTest.php @@ -5,14 +5,12 @@ use Cake\Console\ConsoleIo; use Cake\Datasource\ConnectionManager; +use Migrations\BaseMigration; +use Migrations\BaseSeed; use Migrations\Db\Adapter\AbstractAdapter; use Migrations\Db\Adapter\AdapterWrapper; use Migrations\Migration\Environment; -use Migrations\Shim\MigrationAdapter; -use Migrations\Shim\SeedAdapter; -use Phinx\Migration\AbstractMigration; -use Phinx\Migration\MigrationInterface; -use Phinx\Seed\AbstractSeed; +use Migrations\MigrationInterface; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -123,7 +121,7 @@ public function testExecutingAMigrationUp() $this->environment->setAdapter($adapterStub); // up - $upMigration = new class ('mockenv', 20110301080000) extends AbstractMigration { + $upMigration = new class (20110301080000) extends BaseMigration { public bool $executed = false; public function up(): void { @@ -131,8 +129,7 @@ public function up(): void } }; - $migrationWrapper = new MigrationAdapter($upMigration, $upMigration->getVersion()); - $this->environment->executeMigration($migrationWrapper, MigrationInterface::UP); + $this->environment->executeMigration($upMigration, MigrationInterface::UP); $this->assertTrue($upMigration->executed); } @@ -149,7 +146,7 @@ public function testExecutingAMigrationDown() $this->environment->setAdapter($adapterStub); // down - $downMigration = new class ('mockenv', 20110301080000) extends AbstractMigration { + $downMigration = new class (20110301080000) extends BaseMigration { public bool $executed = false; public function down(): void { @@ -157,8 +154,7 @@ public function down(): void } }; - $migrationWrapper = new MigrationAdapter($downMigration, $downMigration->getVersion()); - $this->environment->executeMigration($migrationWrapper, MigrationInterface::DOWN); + $this->environment->executeMigration($downMigration, MigrationInterface::DOWN); $this->assertTrue($downMigration->executed); } @@ -181,7 +177,7 @@ public function testExecutingAMigrationWithTransactions() $this->environment->setAdapter($adapterStub); // migrate - $migration = new class ('mockenv', 20110301080000) extends AbstractMigration { + $migration = new class (20110301080000) extends BaseMigration { public bool $executed = false; public function up(): void { @@ -189,8 +185,7 @@ public function up(): void } }; - $migrationWrapper = new MigrationAdapter($migration, $migration->getVersion()); - $this->environment->executeMigration($migrationWrapper, MigrationInterface::UP); + $this->environment->executeMigration($migration, MigrationInterface::UP); $this->assertTrue($migration->executed); } @@ -213,7 +208,7 @@ public function testExecutingAMigrationWithUseTransactions() $this->environment->setAdapter($adapterStub); // migrate - $migration = new class ('mockenv', 20110301080000) extends AbstractMigration { + $migration = new class (20110301080000) extends BaseMigration { public bool $executed = false; public function useTransactions(): bool @@ -227,8 +222,7 @@ public function up(): void } }; - $migrationWrapper = new MigrationAdapter($migration, $migration->getVersion()); - $this->environment->executeMigration($migrationWrapper, MigrationInterface::UP); + $this->environment->executeMigration($migration, MigrationInterface::UP); $this->assertTrue($migration->executed); } @@ -245,7 +239,7 @@ public function testExecutingAChangeMigrationUp() $this->environment->setAdapter($adapterStub); // migration - $migration = new class ('mockenv', 20130301080000) extends AbstractMigration { + $migration = new class (20130301080000) extends BaseMigration { public bool $executed = false; public function change(): void { @@ -253,8 +247,7 @@ public function change(): void } }; - $migrationWrapper = new MigrationAdapter($migration, $migration->getVersion()); - $this->environment->executeMigration($migrationWrapper, MigrationInterface::UP); + $this->environment->executeMigration($migration, MigrationInterface::UP); $this->assertTrue($migration->executed); } @@ -271,7 +264,7 @@ public function testExecutingAChangeMigrationDown() $this->environment->setAdapter($adapterStub); // migration - $migration = new class ('mockenv', 20130301080000) extends AbstractMigration { + $migration = new class (20130301080000) extends BaseMigration { public bool $executed = false; public function change(): void { @@ -279,8 +272,7 @@ public function change(): void } }; - $migrationWrapper = new MigrationAdapter($migration, $migration->getVersion()); - $this->environment->executeMigration($migrationWrapper, MigrationInterface::DOWN); + $this->environment->executeMigration($migration, MigrationInterface::DOWN); $this->assertTrue($migration->executed); } @@ -297,7 +289,7 @@ public function testExecutingAFakeMigration() $this->environment->setAdapter($adapterStub); // migration - $migration = new class ('mockenv', 20130301080000) extends AbstractMigration { + $migration = new class (20130301080000) extends BaseMigration { public bool $executed = false; public function change(): void { @@ -305,8 +297,7 @@ public function change(): void } }; - $migrationWrapper = new MigrationAdapter($migration, $migration->getVersion()); - $this->environment->executeMigration($migrationWrapper, MigrationInterface::UP, true); + $this->environment->executeMigration($migration, MigrationInterface::UP, true); $this->assertFalse($migration->executed); } @@ -331,7 +322,7 @@ public function testExecuteMigrationCallsInit() $this->environment->setAdapter($adapterStub); // up - $upMigration = new class ('mockenv', 20110301080000) extends AbstractMigration { + $upMigration = new class (20110301080000) extends BaseMigration { public bool $initExecuted = false; public bool $upExecuted = false; @@ -345,8 +336,7 @@ public function up(): void $this->upExecuted = true; } }; - $migrationWrapper = new MigrationAdapter($upMigration, $upMigration->getVersion()); - $this->environment->executeMigration($migrationWrapper, MigrationInterface::UP); + $this->environment->executeMigration($upMigration, MigrationInterface::UP); $this->assertTrue($upMigration->initExecuted); $this->assertTrue($upMigration->upExecuted); } @@ -360,7 +350,7 @@ public function testExecuteSeedInit() $this->environment->setAdapter($adapterStub); - $seed = new class ('mockenv', 20110301080000) extends AbstractSeed { + $seed = new class (20110301080000) extends BaseSeed { public bool $initExecuted = false; public bool $runExecuted = false; @@ -375,8 +365,7 @@ public function run(): void } }; - $seedWrapper = new SeedAdapter($seed); - $this->environment->executeSeed($seedWrapper); + $this->environment->executeSeed($seed); $this->assertTrue($seed->initExecuted); $this->assertTrue($seed->runExecuted); diff --git a/tests/TestCase/Migration/ManagerFactoryTest.php b/tests/TestCase/Migration/ManagerFactoryTest.php index f7fe3a0a0..0352e887f 100644 --- a/tests/TestCase/Migration/ManagerFactoryTest.php +++ b/tests/TestCase/Migration/ManagerFactoryTest.php @@ -26,6 +26,17 @@ public function testConnection(): void $this->assertSame('test', $result->getConfig()->getConnection()); } + public function testCreateConfigPluginAdapter(): void + { + $factory = new ManagerFactory([ + 'connection' => 'test', + 'plugin' => 'Migrator', + ]); + + $config = $factory->createConfig(); + $this->assertSame('Migrator', $config['environment']['plugin']); + } + public function testDsnConnection(): void { $out = new StubConsoleOutput(); diff --git a/tests/TestCase/Migration/ManagerTest.php b/tests/TestCase/Migration/ManagerTest.php index fb2460340..ba7c324dd 100644 --- a/tests/TestCase/Migration/ManagerTest.php +++ b/tests/TestCase/Migration/ManagerTest.php @@ -13,7 +13,6 @@ use Migrations\Db\Adapter\AdapterInterface; use Migrations\Migration\Environment; use Migrations\Migration\Manager; -use Phinx\Console\Command\AbstractCommand; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ReflectionClass; @@ -22,7 +21,7 @@ class ManagerTest extends TestCase { /** - * @var \Phinx\Config\Config + * @var \Migrations\Config\Config */ protected $config; @@ -245,7 +244,7 @@ public function testPrintStatusMethodJsonFormat(): void ], ); $this->manager->setEnvironment($envStub); - $return = $this->manager->printStatus(AbstractCommand::FORMAT_JSON); + $return = $this->manager->printStatus('json'); $expected = [ [ 'status' => 'up', @@ -640,6 +639,26 @@ public function testGetMigrationsWithInvalidMigrationClassName() $manager->getMigrations(); } + public function testGetMigrationsWithAnonymousClass() + { + $config = new Config(['paths' => ['migrations' => ROOT . '/config/AnonymousMigrations']]); + $manager = new Manager($config, $this->io); + + $migrations = $manager->getMigrations(); + + // Should have one migration + $this->assertCount(1, $migrations); + + // Get the migration + $migration = reset($migrations); + + // Check that it's a valid migration object + $this->assertInstanceOf('\Migrations\MigrationInterface', $migration); + + // Check the version was set correctly (2024_12_08_150000 => 20241208150000) + $this->assertEquals(20241208150000, $migration->getVersion()); + } + public function testGettingAValidEnvironment() { $this->assertInstanceOf( diff --git a/tests/TestCase/MigrationsPluginTest.php b/tests/TestCase/MigrationsPluginTest.php index 4b4bc415a..81f5f2cdc 100644 --- a/tests/TestCase/MigrationsPluginTest.php +++ b/tests/TestCase/MigrationsPluginTest.php @@ -4,21 +4,17 @@ namespace Migrations\Test\TestCase; use Cake\Console\CommandCollection; -use Cake\Core\Configure; use Cake\TestSuite\TestCase; -use Migrations\Command\MigrationsRollbackCommand; use Migrations\Command\RollbackCommand; use Migrations\MigrationsPlugin; class MigrationsPluginTest extends TestCase { /** - * Test that builtin backend uses RollbackCommand + * Test that console() registers the correct RollbackCommand */ - public function testConsoleBuiltinBackendUsesCorrectRollbackCommand(): void + public function testConsoleUsesCorrectRollbackCommand(): void { - Configure::write('Migrations.backend', 'builtin'); - $plugin = new MigrationsPlugin(); $commands = new CommandCollection(); $commands = $plugin->console($commands); @@ -26,22 +22,4 @@ public function testConsoleBuiltinBackendUsesCorrectRollbackCommand(): void $this->assertTrue($commands->has('migrations rollback')); $this->assertSame(RollbackCommand::class, $commands->get('migrations rollback')); } - - /** - * Test that phinx backend uses MigrationsRollbackCommand - * - * This is the reported bug in https://github.com/cakephp/migrations/issues/990 - */ - public function testConsolePhinxBackendUsesCorrectRollbackCommand(): void - { - Configure::write('Migrations.backend', 'phinx'); - - $plugin = new MigrationsPlugin(); - $commands = new CommandCollection(); - $commands = $plugin->console($commands); - - $this->assertTrue($commands->has('migrations rollback')); - // Bug: RollbackCommand is loaded instead of MigrationsRollbackCommand - $this->assertSame(MigrationsRollbackCommand::class, $commands->get('migrations rollback')); - } } diff --git a/tests/TestCase/MigrationsTest.php b/tests/TestCase/MigrationsTest.php index f66aa256b..5601a4ff5 100644 --- a/tests/TestCase/MigrationsTest.php +++ b/tests/TestCase/MigrationsTest.php @@ -22,7 +22,6 @@ use Exception; use InvalidArgumentException; use Migrations\Migrations; -use Phinx\Db\Adapter\WrapperInterface; use PHPUnit\Framework\Attributes\DataProvider; use function Cake\Core\env; @@ -66,43 +65,43 @@ public function setUp(): void 'connection' => 'test', 'source' => 'TestsMigrations', ]; - - // Get the PDO connection to have the same across the various objects needed to run the tests - $migrations = new Migrations(); - $input = $migrations->getInput('Migrate', [], $params); - $migrations->setInput($input); - $migrations->getManager($migrations->getConfig()); - $this->Connection = ConnectionManager::get('test'); - $connection = $migrations->getManager()->getEnvironment('default')->getAdapter()->getConnection(); - $this->setDriverConnection($this->Connection->getDriver(), $connection); - // Get an instance of the Migrations object on which we will run the tests $this->migrations = new Migrations($params); - $adapter = $this->migrations - ->getManager($migrations->getConfig()) - ->getEnvironment('default') - ->getAdapter(); - while ($adapter instanceof WrapperInterface) { - $adapter = $adapter->getAdapter(); - } - $adapter->setConnection($connection); + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $connection->getDriver()->disconnect(); // List of tables managed by migrations this test runs. // We can't wipe all tables as we'l break other tests. - $this->Connection->execute('DROP TABLE IF EXISTS numbers'); - $this->Connection->execute('DROP TABLE IF EXISTS letters'); - $this->Connection->execute('DROP TABLE IF EXISTS stores'); - $this->Connection->execute('DROP TABLE IF EXISTS articles'); + $connection->execute('DROP TABLE IF EXISTS numbers'); + $connection->execute('DROP TABLE IF EXISTS letters'); + $connection->execute('DROP TABLE IF EXISTS stores'); + $connection->execute('DROP TABLE IF EXISTS articles'); - $allTables = $this->Connection->getSchemaCollection()->listTables(); + $allTables = $connection->getSchemaCollection()->listTables(); if (in_array('phinxlog', $allTables)) { $ormTable = $this->getTableLocator()->get('phinxlog', ['connection' => $this->Connection]); - $query = $this->Connection->getDriver()->schemaDialect()->truncateTableSql($ormTable->getSchema()); + $query = $connection->getDriver()->schemaDialect()->truncateTableSql($ormTable->getSchema()); foreach ($query as $stmt) { - $this->Connection->execute($stmt); + $connection->execute($stmt); } } + if (in_array('cake_migrations', $allTables)) { + $ormTable = $this->getTableLocator()->get('cake_migrations', ['connection' => $this->Connection]); + $query = $connection->getDriver()->schemaDialect()->truncateTableSql($ormTable->getSchema()); + foreach ($query as $stmt) { + $connection->execute($stmt); + } + } + if (in_array('cake_seeds', $allTables)) { + $ormTable = $this->getTableLocator()->get('cake_seeds', ['connection' => $this->Connection]); + $query = $connection->getDriver()->schemaDialect()->truncateTableSql($ormTable->getSchema()); + foreach ($query as $stmt) { + $connection->execute($stmt); + } + } + $this->Connection = $connection; } /** @@ -122,24 +121,13 @@ public function tearDown(): void } } - public static function backendProvider(): array - { - return [ - ['builtin'], - ['phinx'], - ]; - } - /** * Tests the status method * * @return void */ - #[DataProvider('backendProvider')] - public function testStatus(string $backend) + public function testStatus() { - Configure::write('Migrations.backend', $backend); - $result = $this->migrations->status(); $expected = [ [ @@ -171,11 +159,8 @@ public function testStatus(string $backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testMigrateAndRollback($backend) + public function testMigrateAndRollback() { - Configure::write('Migrations.backend', $backend); - if ($this->Connection->getDriver() instanceof Sqlserver) { // TODO This test currently fails in CI because numbers table // has no columns in sqlserver. This table should have columns as the @@ -230,7 +215,7 @@ public function testMigrateAndRollback($backend) $storesTable = $this->getTableLocator()->get('Stores', ['connection' => $this->Connection]); $columns = $storesTable->getSchema()->columns(); - $expected = ['id', 'name', 'created', 'modified']; + $expected = ['id', 'name', 'created', 'updated']; $this->assertEquals($expected, $columns); $createdColumn = $storesTable->getSchema()->getColumn('created'); $expected = 'CURRENT_TIMESTAMP'; @@ -263,11 +248,8 @@ public function testMigrateAndRollback($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testCreateWithEncoding($backend) + public function testCreateWithEncoding() { - Configure::write('Migrations.backend', $backend); - $this->skipIf(env('DB') !== 'mysql', 'Requires MySQL'); $migrate = $this->migrations->migrate(); @@ -276,7 +258,8 @@ public function testCreateWithEncoding($backend) // Tests that if a collation is defined, it is used $numbersTable = $this->getTableLocator()->get('Numbers', ['connection' => $this->Connection]); $options = $numbersTable->getSchema()->getOptions(); - $this->assertSame('utf8mb3_bin', $options['collation']); + // MySQL 8.0.30+ normalizes utf8mb3 to utf8 + $this->assertContains($options['collation'], ['utf8mb3_bin', 'utf8_bin']); // Tests that if a collation is not defined, it will use the database default one $lettersTable = $this->getTableLocator()->get('Letters', ['connection' => $this->Connection]); @@ -292,11 +275,8 @@ public function testCreateWithEncoding($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testMarkMigratedAll($backend) + public function testMarkMigratedAll() { - Configure::write('Migrations.backend', $backend); - $markMigrated = $this->migrations->markMigrated(); $this->assertTrue($markMigrated); $status = $this->migrations->status(); @@ -332,11 +312,8 @@ public function testMarkMigratedAll($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testMarkMigratedAllAsVersion($backend) + public function testMarkMigratedAllAsVersion() { - Configure::write('Migrations.backend', $backend); - $markMigrated = $this->migrations->markMigrated('all'); $this->assertTrue($markMigrated); $status = $this->migrations->status(); @@ -371,11 +348,8 @@ public function testMarkMigratedAllAsVersion($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testMarkMigratedTarget($backend) + public function testMarkMigratedTarget() { - Configure::write('Migrations.backend', $backend); - $markMigrated = $this->migrations->markMigrated(null, ['target' => '20150704160200']); $this->assertTrue($markMigrated); $status = $this->migrations->status(); @@ -416,11 +390,8 @@ public function testMarkMigratedTarget($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testMarkMigratedTargetError($backend) + public function testMarkMigratedTargetError() { - Configure::write('Migrations.backend', $backend); - $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Migration `20150704160610` was not found !'); $this->migrations->markMigrated(null, ['target' => '20150704160610']); @@ -432,11 +403,8 @@ public function testMarkMigratedTargetError($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testMarkMigratedTargetExclude($backend) + public function testMarkMigratedTargetExclude() { - Configure::write('Migrations.backend', $backend); - $markMigrated = $this->migrations->markMigrated(null, ['target' => '20150704160200', 'exclude' => true]); $this->assertTrue($markMigrated); $status = $this->migrations->status(); @@ -477,11 +445,8 @@ public function testMarkMigratedTargetExclude($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testMarkMigratedTargetOnly($backend) + public function testMarkMigratedTargetOnly() { - Configure::write('Migrations.backend', $backend); - $markMigrated = $this->migrations->markMigrated(null, ['target' => '20150724233100', 'only' => true]); $this->assertTrue($markMigrated); $status = $this->migrations->status(); @@ -522,11 +487,8 @@ public function testMarkMigratedTargetOnly($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testMarkMigratedTargetExcludeOnly($backend) + public function testMarkMigratedTargetExcludeOnly() { - Configure::write('Migrations.backend', $backend); - $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('You should use `exclude` OR `only` (not both) along with a `target` argument'); $this->migrations->markMigrated(null, ['target' => '20150724233100', 'only' => true, 'exclude' => true]); @@ -538,11 +500,8 @@ public function testMarkMigratedTargetExcludeOnly($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testMarkMigratedVersion($backend) + public function testMarkMigratedVersion() { - Configure::write('Migrations.backend', $backend); - $markMigrated = $this->migrations->markMigrated(20150704160200); $this->assertTrue($markMigrated); $status = $this->migrations->status(); @@ -583,11 +542,8 @@ public function testMarkMigratedVersion($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testOverrideOptions($backend) + public function testOverrideOptions() { - Configure::write('Migrations.backend', $backend); - $result = $this->migrations->status(); $expectedStatus = [ [ @@ -654,11 +610,8 @@ public function testOverrideOptions($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testMigrateDateOption($backend) + public function testMigrateDateOption() { - Configure::write('Migrations.backend', $backend); - // If we want to migrate to a date before the first first migration date, // we should not migrate anything $this->migrations->migrate(['date' => '20140705']); @@ -833,11 +786,8 @@ public function testMigrateDateOption($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testSeed($backend) + public function testSeed() { - Configure::write('Migrations.backend', $backend); - $this->migrations->migrate(); $seed = $this->migrations->seed(['source' => 'Seeds']); $this->assertTrue($seed); @@ -855,7 +805,7 @@ public function testSeed($backend) ]; $this->assertEquals($expected, $result); - $seed = $this->migrations->seed(['source' => 'Seeds']); + $seed = $this->migrations->seed(['source' => 'Seeds', 'force' => true]); $this->assertTrue($seed); $result = $this->Connection->selectQuery() ->select(['*']) @@ -875,7 +825,7 @@ public function testSeed($backend) ]; $this->assertEquals($expected, $result); - $seed = $this->migrations->seed(['source' => 'AltSeeds']); + $seed = $this->migrations->seed(['source' => 'AltSeeds', 'force' => true]); $this->assertTrue($seed); $result = $this->Connection->selectQuery() ->select(['*']) @@ -912,11 +862,8 @@ public function testSeed($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testSeedOneSeeder($backend) + public function testSeedOneSeeder() { - Configure::write('Migrations.backend', $backend); - $this->migrations->migrate(); $seed = $this->migrations->seed(['source' => 'AltSeeds', 'seed' => 'AnotherNumbersSeed']); @@ -966,10 +913,6 @@ public function testSeedOneSeeder($backend) */ public function testSeedOneSeederShortName() { - // This only works for Migrations built in. - $backend = 'builtin'; - Configure::write('Migrations.backend', $backend); - $this->migrations->migrate(); $seed = $this->migrations->seed(['source' => 'AltSeeds', 'seed' => 'AnotherNumbers']); @@ -1017,11 +960,8 @@ public function testSeedOneSeederShortName() * * @return void */ - #[DataProvider('backendProvider')] - public function testSeedCallSeeder($backend) + public function testSeedCallSeeder() { - Configure::write('Migrations.backend', $backend); - $this->migrations->migrate(); $seed = $this->migrations->seed(['source' => 'CallSeeds', 'seed' => 'DatabaseSeed']); @@ -1081,36 +1021,14 @@ public function testSeedCallSeeder($backend) * * @return void */ - #[DataProvider('backendProvider')] - public function testSeedWrongSeed($backend) + public function testSeedWrongSeed() { - Configure::write('Migrations.backend', $backend); - $this->expectException(InvalidArgumentException::class); - if ($backend === 'builtin') { - $this->expectExceptionMessage('The seed `DerpSeed` does not exist'); - } else { - $this->expectExceptionMessage('The seed class "DerpSeed" does not exist'); - } + $this->expectExceptionMessage('The seed `DerpSeed` does not exist'); $this->migrations->seed(['source' => 'AltSeeds', 'seed' => 'DerpSeed']); } - /** - * Tests migrating the baked snapshots with builtin backend - * - * @param string $basePath Snapshot file path - * @param string $filename Snapshot file name - * @param array $flags Feature flags - * @return void - */ - #[DataProvider('snapshotMigrationsProvider')] - public function testMigrateSnapshotsBuiltin(string $basePath, string $filename, array $flags = []): void - { - Configure::write('Migrations.backend', 'builtin'); - $this->runMigrateSnapshots($basePath, $filename, $flags); - } - /** * Tests migrating the baked snapshots * @@ -1120,12 +1038,7 @@ public function testMigrateSnapshotsBuiltin(string $basePath, string $filename, * @return void */ #[DataProvider('snapshotMigrationsProvider')] - public function testMigrateSnapshotsPhinx(string $basePath, string $filename, array $flags = []): void - { - $this->runMigrateSnapshots($basePath, $filename, $flags); - } - - protected function runMigrateSnapshots(string $basePath, string $filename, array $flags): void + public function testMigrateSnapshots(string $basePath, string $filename, array $flags = []): void { if ($this->Connection->getDriver() instanceof Sqlserver) { // TODO once migrations is using the inlined sqlserver adapter, this skip should @@ -1157,7 +1070,7 @@ protected function runMigrateSnapshots(string $basePath, string $filename, array // change class name to avoid conflict with other classes // to avoid 'Fatal error: Cannot declare class Test...., because the name is already in use' $content = file_get_contents($destination . $copiedFileName); - $patterns = [' extends AbstractMigration', ' extends BaseMigration']; + $patterns = [' extends BaseMigration']; foreach ($patterns as $pattern) { $content = str_replace($pattern, 'NewSuffix' . $pattern, $content); } @@ -1186,11 +1099,8 @@ protected function runMigrateSnapshots(string $basePath, string $filename, array /** * Tests that migrating in case of error throws an exception */ - #[DataProvider('backendProvider')] - public function testMigrateErrors($backend) + public function testMigrateErrors() { - Configure::write('Migrations.backend', $backend); - $this->expectException(Exception::class); $this->migrations->markMigrated(20150704160200); $this->migrations->migrate(); @@ -1199,11 +1109,8 @@ public function testMigrateErrors($backend) /** * Tests that rolling back in case of error throws an exception */ - #[DataProvider('backendProvider')] - public function testRollbackErrors($backend) + public function testRollbackErrors() { - Configure::write('Migrations.backend', $backend); - $this->expectException(Exception::class); $this->migrations->markMigrated('all'); $this->migrations->rollback(); @@ -1213,11 +1120,8 @@ public function testRollbackErrors($backend) * Tests that marking migrated a non-existant migrations returns an error * and can return a error message */ - #[DataProvider('backendProvider')] - public function testMarkMigratedErrors($backend) + public function testMarkMigratedErrors() { - Configure::write('Migrations.backend', $backend); - $this->expectException(Exception::class); $this->migrations->markMigrated(20150704000000); } diff --git a/tests/TestCase/TestCase.php b/tests/TestCase/TestCase.php index 859e9c2e5..06b0f68ec 100644 --- a/tests/TestCase/TestCase.php +++ b/tests/TestCase/TestCase.php @@ -17,9 +17,12 @@ namespace Migrations\Test\TestCase; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; +use Cake\Core\Configure; +use Cake\Datasource\ConnectionManager; use Cake\Routing\Router; use Cake\TestSuite\StringCompareTrait; use Cake\TestSuite\TestCase as BaseTestCase; +use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; abstract class TestCase extends BaseTestCase { @@ -126,4 +129,133 @@ protected function assertFileNotContains($expected, $path, $message = '') $contents = file_get_contents($path); $this->assertStringNotContainsString($expected, $contents, $message); } + + /** + * Check if using unified migrations table. + * + * @return bool + */ + protected function isUsingUnifiedTable(): bool + { + return Configure::read('Migrations.legacyTables') === false; + } + + /** + * Get the migrations schema table name. + * + * @param string|null $plugin Plugin name + * @return string + */ + protected function getMigrationsTableName(?string $plugin = null): string + { + if ($this->isUsingUnifiedTable()) { + return UnifiedMigrationsTableStorage::TABLE_NAME; + } + + if ($plugin === null) { + return 'phinxlog'; + } + + return strtolower($plugin) . '_phinxlog'; + } + + /** + * Clear migration records from the schema table. + * + * @param string $connectionName Connection name + * @param string|null $plugin Plugin name + * @return void + */ + protected function clearMigrationRecords(string $connectionName = 'test', ?string $plugin = null): void + { + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get($connectionName); + $tableName = $this->getMigrationsTableName($plugin); + + $dialect = $connection->getDriver()->schemaDialect(); + if (!$dialect->hasTable($tableName)) { + return; + } + + if ($this->isUsingUnifiedTable()) { + $query = $connection->deleteQuery() + ->delete($tableName) + ->where(['plugin IS' => $plugin]); + } else { + $query = $connection->deleteQuery() + ->delete($tableName); + } + $query->execute(); + } + + /** + * Get the count of migration records. + * + * @param string $connectionName Connection name + * @param string|null $plugin Plugin name + * @return int + */ + protected function getMigrationRecordCount(string $connectionName = 'test', ?string $plugin = null): int + { + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get($connectionName); + $tableName = $this->getMigrationsTableName($plugin); + + $dialect = $connection->getDriver()->schemaDialect(); + if (!$dialect->hasTable($tableName)) { + return 0; + } + + $query = $connection->selectQuery() + ->select(['count' => $connection->selectQuery()->func()->count('*')]) + ->from($tableName); + + if ($this->isUsingUnifiedTable()) { + $query->where(['plugin IS' => $plugin]); + } + + $result = $query->execute()->fetch('assoc'); + + return (int)($result['count'] ?? 0); + } + + /** + * Insert a migration record into the schema table. + * + * @param string $connectionName Connection name + * @param int $version Version number + * @param string $migrationName Migration name + * @param string|null $plugin Plugin name + * @return void + */ + protected function insertMigrationRecord( + string $connectionName, + int $version, + string $migrationName, + ?string $plugin = null, + ): void { + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get($connectionName); + $tableName = $this->getMigrationsTableName($plugin); + + $columns = ['version', 'migration_name', 'start_time', 'end_time', 'breakpoint']; + $values = [ + 'version' => $version, + 'migration_name' => $migrationName, + 'start_time' => '2024-01-01 00:00:00', + 'end_time' => '2024-01-01 00:00:01', + 'breakpoint' => 0, + ]; + + if ($this->isUsingUnifiedTable()) { + $columns[] = 'plugin'; + $values['plugin'] = $plugin; + } + + $connection->insertQuery() + ->insert($columns) + ->into($tableName) + ->values($values) + ->execute(); + } } diff --git a/tests/TestCase/TestSuite/MigratorTest.php b/tests/TestCase/TestSuite/MigratorTest.php index 4fa05b2d4..de3baa975 100644 --- a/tests/TestCase/TestSuite/MigratorTest.php +++ b/tests/TestCase/TestSuite/MigratorTest.php @@ -14,10 +14,12 @@ namespace Migrations\Test\TestCase\TestSuite; use Cake\Chronos\ChronosDate; +use Cake\Core\Configure; use Cake\Database\Driver\Postgres; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\ConnectionHelper; use Cake\TestSuite\TestCase; +use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; use Migrations\TestSuite\Migrator; use PHPUnit\Framework\Attributes\Depends; use RuntimeException; @@ -29,6 +31,30 @@ class MigratorTest extends TestCase */ protected $restore; + /** + * Get the migration table name for the Migrator plugin. + * + * @return string + */ + protected function getMigratorTableName(): string + { + return Configure::read('Migrations.legacyTables') === false + ? UnifiedMigrationsTableStorage::TABLE_NAME + : 'migrator_phinxlog'; + } + + /** + * Build a WHERE clause for filtering by plugin in unified mode. + * + * @return array + */ + protected function getMigratorWhereClause(): array + { + return Configure::read('Migrations.legacyTables') === false + ? ['plugin' => 'Migrator'] + : []; + } + public function setUp(): void { parent::setUp(); @@ -112,13 +138,20 @@ public function testRunManyDropTruncate(): void $tables = $connection->getSchemaCollection()->listTables(); $this->assertContains('migrator', $tables); $this->assertCount(0, $connection->selectQuery()->select(['*'])->from('migrator')->execute()->fetchAll()); - $this->assertCount(2, $connection->selectQuery()->select(['*'])->from('migrator_phinxlog')->execute()->fetchAll()); + $query = $connection->selectQuery()->select(['*'])->from($this->getMigratorTableName()); + $where = $this->getMigratorWhereClause(); + if ($where) { + $query->where($where); + } + $this->assertCount(2, $query->execute()->fetchAll()); } public function testRunManyMultipleSkip(): void { $connection = ConnectionManager::get('test'); $this->skipIf($connection->getDriver() instanceof Postgres); + // Skip for unified mode - migration history detection works differently + $this->skipIf(Configure::read('Migrations.legacyTables') === false); $migrator = new Migrator(); // Run migrations for the first time. @@ -154,19 +187,26 @@ public function testTruncateAfterMigrations(): void private function setMigrationEndDateToYesterday() { - ConnectionManager::get('test')->updateQuery() - ->update('migrator_phinxlog') - ->set('end_time', ChronosDate::yesterday(), 'timestamp') - ->execute(); + $query = ConnectionManager::get('test')->updateQuery() + ->update($this->getMigratorTableName()) + ->set('end_time', ChronosDate::yesterday(), 'timestamp'); + $where = $this->getMigratorWhereClause(); + if ($where) { + $query->where($where); + } + $query->execute(); } private function fetchMigrationEndDate(): ChronosDate { - $endTime = ConnectionManager::get('test')->selectQuery() + $query = ConnectionManager::get('test')->selectQuery() ->select('end_time') - ->from('migrator_phinxlog') - ->execute() - ->fetchColumn(0); + ->from($this->getMigratorTableName()); + $where = $this->getMigratorWhereClause(); + if ($where) { + $query->where($where); + } + $endTime = $query->execute()->fetchColumn(0); if (!$endTime || is_bool($endTime)) { $this->markTestSkipped('Cannot read end_time, bailing.'); @@ -217,6 +257,9 @@ public function testSkipMigrationDroppingIfOnlyUpMigrationsWithTwoSetsOfMigratio public function testDropMigrationsIfDownMigrations(): void { + // Skip for unified mode - migration history detection works differently + $this->skipIf(Configure::read('Migrations.legacyTables') === false); + // Run the migrator $migrator = new Migrator(); $migrator->run(['plugin' => 'Migrator']); @@ -237,6 +280,9 @@ public function testDropMigrationsIfDownMigrations(): void public function testDropMigrationsIfMissingMigrations(): void { + // Skip for unified mode - migration history detection works differently + $this->skipIf(Configure::read('Migrations.legacyTables') === false); + // Run the migrator $migrator = new Migrator(); $migrator->runMany([ diff --git a/tests/TestCase/Util/ColumnParserTest.php b/tests/TestCase/Util/ColumnParserTest.php index 1a31d6717..d56d0616a 100644 --- a/tests/TestCase/Util/ColumnParserTest.php +++ b/tests/TestCase/Util/ColumnParserTest.php @@ -346,6 +346,36 @@ public function testGetTypeAndLength() $this->assertEquals(['decimal', [10, 6]], $this->columnParser->getTypeAndLength('latitude', 'decimal[10,6]')); } + public function testGetTypeAndLengthReturnsIntegerTypes() + { + // Test that lengths are returned as integers, not strings + [, $length] = $this->columnParser->getTypeAndLength('name', 'string[128]'); + $this->assertIsInt($length); + $this->assertSame(128, $length); + + [, $length] = $this->columnParser->getTypeAndLength('count', 'integer[9]'); + $this->assertIsInt($length); + $this->assertSame(9, $length); + + // Test that precision/scale arrays contain integers + [, $length] = $this->columnParser->getTypeAndLength('amount', 'decimal[10,6]'); + $this->assertIsArray($length); + $this->assertCount(2, $length); + $this->assertIsInt($length[0]); + $this->assertIsInt($length[1]); + $this->assertSame(10, $length[0]); + $this->assertSame(6, $length[1]); + + // Test default lengths are also integers + [, $length] = $this->columnParser->getTypeAndLength('name', 'string'); + $this->assertIsInt($length); + $this->assertSame(255, $length); + + [, $length] = $this->columnParser->getTypeAndLength('id', 'integer'); + $this->assertIsInt($length); + $this->assertSame(11, $length); + } + public function testGetLength() { $this->assertSame(255, $this->columnParser->getLength('string')); @@ -413,6 +443,190 @@ public function testParseFieldsWithReferences() $this->assertEquals($expected, $actual); } + public function testParseFieldsWithDefaultValues() + { + // Test boolean default true + $expected = [ + 'active' => [ + 'columnType' => 'boolean', + 'options' => [ + 'null' => false, + 'default' => true, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['active:boolean:default[true]']); + $this->assertEquals($expected, $actual); + + // Test boolean default false + $expected = [ + 'skip_updates' => [ + 'columnType' => 'boolean', + 'options' => [ + 'null' => false, + 'default' => false, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['skip_updates:boolean:default[false]']); + $this->assertEquals($expected, $actual); + + // Test integer default + $expected = [ + 'count' => [ + 'columnType' => 'integer', + 'options' => [ + 'null' => false, + 'default' => 0, + 'limit' => 11, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['count:integer:default[0]']); + $this->assertEquals($expected, $actual); + + // Test string default with quotes + $expected = [ + 'status' => [ + 'columnType' => 'string', + 'options' => [ + 'null' => false, + 'default' => 'pending', + 'limit' => 255, + ], + ], + ]; + $actual = $this->columnParser->parseFields(["status:string:default['pending']"]); + $this->assertEquals($expected, $actual); + + // Test nullable with default + $expected = [ + 'role' => [ + 'columnType' => 'string', + 'options' => [ + 'null' => true, + 'default' => 'user', + 'limit' => 255, + ], + ], + ]; + $actual = $this->columnParser->parseFields(["role:string?:default['user']"]); + $this->assertEquals($expected, $actual); + + // Test default with index + $expected = [ + 'email' => [ + 'columnType' => 'string', + 'options' => [ + 'null' => false, + 'default' => null, + 'limit' => 255, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['email:string:default[null]:unique']); + $this->assertEquals($expected, $actual); + + // Test float default + $expected = [ + 'rate' => [ + 'columnType' => 'decimal', + 'options' => [ + 'null' => false, + 'default' => 1.5, + 'precision' => 10, + 'scale' => 6, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['rate:decimal:default[1.5]']); + $this->assertEquals($expected, $actual); + + // Test length with default + $expected = [ + 'code' => [ + 'columnType' => 'string', + 'options' => [ + 'null' => false, + 'default' => 'ABC', + 'limit' => 10, + ], + ], + ]; + $actual = $this->columnParser->parseFields(["code:string[10]:default['ABC']"]); + $this->assertEquals($expected, $actual); + } + + public function testParseDefaultValue() + { + // Test null and empty values + $this->assertNull($this->columnParser->parseDefaultValue(null, 'string')); + $this->assertNull($this->columnParser->parseDefaultValue('', 'string')); + $this->assertNull($this->columnParser->parseDefaultValue('null', 'string')); + $this->assertNull($this->columnParser->parseDefaultValue('NULL', 'string')); + + // Test boolean values + $this->assertTrue($this->columnParser->parseDefaultValue('true', 'boolean')); + $this->assertTrue($this->columnParser->parseDefaultValue('TRUE', 'boolean')); + $this->assertFalse($this->columnParser->parseDefaultValue('false', 'boolean')); + $this->assertFalse($this->columnParser->parseDefaultValue('FALSE', 'boolean')); + + // Test integer values + $this->assertSame(0, $this->columnParser->parseDefaultValue('0', 'integer')); + $this->assertSame(123, $this->columnParser->parseDefaultValue('123', 'integer')); + $this->assertSame(-456, $this->columnParser->parseDefaultValue('-456', 'integer')); + + // Test float values + $this->assertSame(1.5, $this->columnParser->parseDefaultValue('1.5', 'decimal')); + $this->assertSame(-2.75, $this->columnParser->parseDefaultValue('-2.75', 'decimal')); + + // Test quoted strings + $this->assertSame('hello', $this->columnParser->parseDefaultValue("'hello'", 'string')); + $this->assertSame('world', $this->columnParser->parseDefaultValue('"world"', 'string')); + + // Test SQL expressions (returned as-is) + $this->assertSame('CURRENT_TIMESTAMP', $this->columnParser->parseDefaultValue('CURRENT_TIMESTAMP', 'datetime')); + } + + public function testParseIndexesWithDefaultValues() + { + // Ensure indexes still work with default values in the definition + $expected = [ + 'UNIQUE_EMAIL' => [ + 'columns' => ['email'], + 'options' => ['unique' => true, 'name' => 'UNIQUE_EMAIL'], + ], + ]; + $actual = $this->columnParser->parseIndexes(['email:string:default[null]:unique']); + $this->assertEquals($expected, $actual); + + // Test with custom index name + $expected = [ + 'IDX_COUNT' => [ + 'columns' => ['count'], + 'options' => ['unique' => false, 'name' => 'IDX_COUNT'], + ], + ]; + $actual = $this->columnParser->parseIndexes(['count:integer:default[0]:index:IDX_COUNT']); + $this->assertEquals($expected, $actual); + } + + public function testValidArgumentsWithDefaultValues() + { + $this->assertEquals( + ['active:boolean:default[true]'], + $this->columnParser->validArguments(['active:boolean:default[true]']), + ); + $this->assertEquals( + ['count:integer:default[0]:unique'], + $this->columnParser->validArguments(['count:integer:default[0]:unique']), + ); + $this->assertEquals( + ["status:string:default['pending']:index:IDX_STATUS"], + $this->columnParser->validArguments(["status:string:default['pending']:index:IDX_STATUS"]), + ); + } + public function testParseForeignKeys() { // Test basic reference - infer table name from field diff --git a/tests/TestCase/Util/UtilTest.php b/tests/TestCase/Util/UtilTest.php index 31ef22b30..c017c7641 100644 --- a/tests/TestCase/Util/UtilTest.php +++ b/tests/TestCase/Util/UtilTest.php @@ -57,6 +57,13 @@ public function testGetVersionFromFileName(): void $this->assertSame(20221130101652, Util::getVersionFromFileName('20221130101652_test.php')); } + public function testGetVersionFromReadableFileName(): void + { + // Test readable format: 2024_12_08_120000_CreateUsersTable.php + $this->assertSame(20241208120000, Util::getVersionFromFileName('2024_12_08_120000_CreateUsersTable.php')); + $this->assertSame(20231225235959, Util::getVersionFromFileName('2023_12_25_235959_AddFieldToProducts.php')); + } + public function testGetVersionFromFileNameErrorNoVersion(): void { $this->expectException(RuntimeException::class); @@ -102,6 +109,14 @@ public function testMapFileNameToClassName(string $fileName, string $className) $this->assertEquals($className, Util::mapFileNameToClassName($fileName)); } + public function testMapReadableFileNameToClassName(): void + { + // Test readable format: 2024_12_08_120000_CreateUsersTable.php + $this->assertEquals('CreateUsersTable', Util::mapFileNameToClassName('2024_12_08_120000_CreateUsersTable.php')); + $this->assertEquals('AddFieldToProducts', Util::mapFileNameToClassName('2023_12_25_235959_AddFieldToProducts.php')); + $this->assertEquals('DropOrdersTable', Util::mapFileNameToClassName('2024_01_01_000000_DropOrdersTable.php')); + } + public function testGlobPath() { $files = Util::glob(__DIR__ . '/_files/migrations/empty.txt'); @@ -143,4 +158,22 @@ public function testGetFiles() $this->assertEquals('not_a_migration.php', basename($files[2])); $this->assertEquals('foobar.php', basename($files[3])); } + + public function testIsValidMigrationFileName(): void + { + // Traditional format + $this->assertTrue(Util::isValidMigrationFileName('20221130101652_create_users_table.php')); + $this->assertTrue(Util::isValidMigrationFileName('20120111235330_test_migration.php')); + + // No name format + $this->assertTrue(Util::isValidMigrationFileName('20221130101652.php')); + + // Readable format + $this->assertTrue(Util::isValidMigrationFileName('2024_12_08_120000_CreateUsersTable.php')); + $this->assertTrue(Util::isValidMigrationFileName('2023_12_25_235959_AddFieldToProducts.php')); + + // Invalid formats + $this->assertFalse(Util::isValidMigrationFileName('not_a_migration.php')); + $this->assertFalse(Util::isValidMigrationFileName('2024_12_08_120000_camelCaseShouldStartWithCapital.php')); + } } diff --git a/tests/TestCase/Util/_files/migrations/20120111235330_test_migration.php b/tests/TestCase/Util/_files/migrations/20120111235330_test_migration.php index 27b10239e..fd8abe276 100644 --- a/tests/TestCase/Util/_files/migrations/20120111235330_test_migration.php +++ b/tests/TestCase/Util/_files/migrations/20120111235330_test_migration.php @@ -1,9 +1,9 @@ null, 'precision' => 6, ]; + $this->types = [ + 'timestamp' => 'timestampfractional', + ]; } if (getenv('DB') === 'sqlserver') { @@ -120,6 +128,9 @@ public function setUp(): void 'comment' => null, 'precision' => 7, ]; + $this->types = [ + 'timestamp' => 'datetimefractional', + ]; } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a8c46048c..cf4caad6f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -13,6 +13,7 @@ */ use Bake\BakePlugin; +use Blog\BlogPlugin; use Cake\Cache\Cache; use Cake\Core\Configure; use Cake\Core\Plugin; @@ -20,8 +21,7 @@ use Cake\Routing\Router; use Cake\TestSuite\Fixture\SchemaLoader; use Migrations\MigrationsPlugin; -use SimpleSnapshot\SimpleSnapshotPlugin; -use TestBlog\TestBlogPlugin; +use Migrator\MigratorPlugin; use function Cake\Core\env; $findRoot = function ($root) { @@ -67,9 +67,13 @@ ], ]); +// LEGACY_TABLES env: 'true' for legacy phinxlog, 'false' for unified cake_migrations +$legacyTables = env('LEGACY_TABLES', 'true') !== 'false'; + Configure::write('Migrations', [ 'unsigned_primary_keys' => true, 'column_null_default' => true, + 'legacyTables' => $legacyTables, ]); Cache::setConfig([ @@ -119,12 +123,8 @@ Plugin::getCollection() ->add(new MigrationsPlugin()) ->add(new BakePlugin()) - ->add(new SimpleSnapshotPlugin()) - ->add(new TestBlogPlugin()); - -if (!defined('PHINX_VERSION')) { - define('PHINX_VERSION', strpos('@PHINX_VERSION@', '@PHINX_VERSION') === 0 ? 'UNKNOWN' : '@PHINX_VERSION@'); -} + ->add(new BlogPlugin()) + ->add(new MigratorPlugin()); // Create test database schema if (env('FIXTURE_SCHEMA_METADATA')) { diff --git a/tests/comparisons/Create/TestCreateChange.php b/tests/comparisons/Create/TestCreateChange.php index 1120f80de..071aa889f 100644 --- a/tests/comparisons/Create/TestCreateChange.php +++ b/tests/comparisons/Create/TestCreateChange.php @@ -1,15 +1,15 @@ null, 'limit' => null, 'null' => false, - 'signed' => true, ]) ->update(); @@ -42,7 +41,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Diff/addRemove/the_diff_add_remove_pgsql.php b/tests/comparisons/Diff/addRemove/the_diff_add_remove_pgsql.php index 831660014..28fda29b9 100644 --- a/tests/comparisons/Diff/addRemove/the_diff_add_remove_pgsql.php +++ b/tests/comparisons/Diff/addRemove/the_diff_add_remove_pgsql.php @@ -1,15 +1,15 @@ table('test_decimal_types') + ->addColumn('amount', 'decimal', [ + 'default' => null, + 'null' => false, + 'precision' => 5, + 'scale' => 2, + ]) + ->create(); + } + + /** + * Down Method. + * + * More information on this method is available here: + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method + * + * @return void + */ + public function down(): void + { + $this->table('test_decimal_types')->drop()->save(); + } +} diff --git a/tests/comparisons/Diff/decimalChange/mysql/the_diff_decimal_change_mysql.php b/tests/comparisons/Diff/decimalChange/mysql/the_diff_decimal_change_mysql.php new file mode 100644 index 000000000..a32449e17 --- /dev/null +++ b/tests/comparisons/Diff/decimalChange/mysql/the_diff_decimal_change_mysql.php @@ -0,0 +1,61 @@ +table('test_decimal_types') + ->changeColumn('id', 'integer', [ + 'default' => null, + 'length' => null, + 'limit' => null, + 'null' => false, + ]) + ->changeColumn('amount', 'decimal', [ + 'default' => null, + 'null' => false, + 'precision' => 5, + 'scale' => 2, + ]) + ->update(); + } + + /** + * Down Method. + * + * More information on this method is available here: + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method + * + * @return void + */ + public function down(): void + { + + $this->table('test_decimal_types') + ->changeColumn('id', 'integer', [ + 'autoIncrement' => true, + 'default' => null, + 'length' => 11, + 'null' => false, + ]) + ->changeColumn('amount', 'decimal', [ + 'default' => null, + 'null' => false, + 'precision' => 10, + 'scale' => 2, + ]) + ->update(); + } +} diff --git a/tests/comparisons/Diff/decimalChange/schema-dump-test_comparisons_mysql.lock b/tests/comparisons/Diff/decimalChange/schema-dump-test_comparisons_mysql.lock new file mode 100644 index 000000000..9c497e7b2 Binary files /dev/null and b/tests/comparisons/Diff/decimalChange/schema-dump-test_comparisons_mysql.lock differ diff --git a/tests/comparisons/Diff/default/schema-dump-test_comparisons_mysql.lock b/tests/comparisons/Diff/default/schema-dump-test_comparisons_mysql.lock index 0cc5d0bb4..10092ee02 100644 Binary files a/tests/comparisons/Diff/default/schema-dump-test_comparisons_mysql.lock and b/tests/comparisons/Diff/default/schema-dump-test_comparisons_mysql.lock differ diff --git a/tests/comparisons/Diff/default/the_diff_default_mysql.php b/tests/comparisons/Diff/default/the_diff_default_mysql.php index 47579e80d..a889d9e7c 100644 --- a/tests/comparisons/Diff/default/the_diff_default_mysql.php +++ b/tests/comparisons/Diff/default/the_diff_default_mysql.php @@ -9,7 +9,7 @@ class TheDiffDefaultMysql extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/migrations/4/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -29,7 +29,6 @@ public function up(): void 'length' => null, 'limit' => null, 'null' => false, - 'signed' => true, ]) ->changeColumn('title', 'text', [ 'default' => null, @@ -53,7 +52,15 @@ public function up(): void 'length' => null, 'limit' => null, 'null' => false, - 'signed' => true, + ]) + ->update(); + + $this->table('tags') + ->changeColumn('id', 'integer', [ + 'default' => null, + 'length' => null, + 'limit' => null, + 'null' => false, ]) ->update(); @@ -63,7 +70,6 @@ public function up(): void 'length' => null, 'limit' => null, 'null' => false, - 'signed' => true, ]) ->update(); $this->table('categories') @@ -76,7 +82,7 @@ public function up(): void 'default' => null, 'limit' => null, 'null' => false, - 'signed' => true, + 'signed' => false, ]) ->addIndex( $this->index('user_id') @@ -113,7 +119,6 @@ public function up(): void 'null' => true, 'precision' => 5, 'scale' => 5, - 'signed' => true, ]) ->addIndex( $this->index('slug') @@ -128,26 +133,13 @@ public function up(): void ->setName('rating_index') ) ->update(); - - $this->table('articles') - ->addForeignKey( - $this->foreignKey('category_id') - ->setReferencedTable('categories') - ->setReferencedColumns('id') - ->setOnDelete('NO_ACTION') - ->setOnUpdate('NO_ACTION') - ->setName('articles_ibfk_1') - ) - ->update(); - - $this->table('tags')->drop()->save(); } /** * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ @@ -158,18 +150,6 @@ public function down(): void 'user_id' )->save(); - $this->table('articles') - ->dropForeignKey( - 'category_id' - )->save(); - $this->table('tags') - ->addColumn('name', 'string', [ - 'default' => null, - 'limit' => 255, - 'null' => false, - ]) - ->create(); - $this->table('articles') ->removeIndexByName('UNIQUE_SLUG') ->removeIndexByName('category_id') @@ -229,6 +209,15 @@ public function down(): void ) ->update(); + $this->table('tags') + ->changeColumn('id', 'integer', [ + 'autoIncrement' => true, + 'default' => null, + 'length' => 11, + 'null' => false, + ]) + ->update(); + $this->table('users') ->changeColumn('id', 'integer', [ 'autoIncrement' => true, diff --git a/tests/comparisons/Diff/default/the_diff_default_pgsql.php b/tests/comparisons/Diff/default/the_diff_default_pgsql.php index b0b892c8e..54b61d85f 100644 --- a/tests/comparisons/Diff/default/the_diff_default_pgsql.php +++ b/tests/comparisons/Diff/default/the_diff_default_pgsql.php @@ -1,15 +1,15 @@ null, 'limit' => null, 'null' => false, - 'signed' => true, ]) ->changeColumn('rating', 'integer', [ 'default' => null, @@ -31,6 +30,14 @@ public function up(): void 'null' => false, ]) ->update(); + $this->table('tags') + ->addColumn('name', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => false, + ]) + ->create(); + $this->table('users') ->addColumn('username', 'string', [ 'default' => null, @@ -74,7 +81,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ @@ -104,6 +111,7 @@ public function down(): void ->removeColumn('user_id') ->update(); + $this->table('tags')->drop()->save(); $this->table('users')->drop()->save(); } } diff --git a/tests/comparisons/Diff/simple/the_diff_simple_pgsql.php b/tests/comparisons/Diff/simple/the_diff_simple_pgsql.php index 448909174..948bcc954 100644 --- a/tests/comparisons/Diff/simple/the_diff_simple_pgsql.php +++ b/tests/comparisons/Diff/simple/the_diff_simple_pgsql.php @@ -1,15 +1,15 @@ null, 'limit' => null, 'null' => false, - 'signed' => true, + 'signed' => false, ]) ->addPrimaryKey(['id']) ->create(); @@ -35,7 +35,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ @@ -47,7 +47,6 @@ public function down(): void 'default' => null, 'limit' => null, 'null' => false, - 'signed' => true, ]) ->addPrimaryKey(['id']) ->create(); diff --git a/tests/comparisons/Diff/withAutoIdIncompatibleUnsignedPrimaryKeys/the_diff_with_auto_id_incompatible_unsigned_primary_keys_mysql.php b/tests/comparisons/Diff/withAutoIdIncompatibleUnsignedPrimaryKeys/the_diff_with_auto_id_incompatible_unsigned_primary_keys_mysql.php index d1ae37731..5d06abc3e 100644 --- a/tests/comparisons/Diff/withAutoIdIncompatibleUnsignedPrimaryKeys/the_diff_with_auto_id_incompatible_unsigned_primary_keys_mysql.php +++ b/tests/comparisons/Diff/withAutoIdIncompatibleUnsignedPrimaryKeys/the_diff_with_auto_id_incompatible_unsigned_primary_keys_mysql.php @@ -11,7 +11,7 @@ class TheDiffWithAutoIdIncompatibleUnsignedPrimaryKeysMysql extends BaseMigratio * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/migrations/4/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -35,7 +35,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/pgsql/test_snapshot_auto_id_disabled_pgsql.php b/tests/comparisons/Migration/pgsql/test_snapshot_auto_id_disabled_pgsql.php index b2596a169..eca100cc3 100644 --- a/tests/comparisons/Migration/pgsql/test_snapshot_auto_id_disabled_pgsql.php +++ b/tests/comparisons/Migration/pgsql/test_snapshot_auto_id_disabled_pgsql.php @@ -11,7 +11,7 @@ class TestSnapshotAutoIdDisabledPgsql extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -56,14 +56,14 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -99,14 +99,14 @@ public function up(): void 'limit' => 100, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -229,14 +229,14 @@ public function up(): void 'limit' => 10, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -304,7 +304,7 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('highlighted_time', 'timestamp', [ + ->addColumn('highlighted_time', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -349,14 +349,14 @@ public function up(): void 'limit' => 256, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('updated', 'timestamp', [ + ->addColumn('updated', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -409,7 +409,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/pgsql/test_snapshot_not_empty_pgsql.php b/tests/comparisons/Migration/pgsql/test_snapshot_not_empty_pgsql.php index 4c8ad496f..e297c18ce 100644 --- a/tests/comparisons/Migration/pgsql/test_snapshot_not_empty_pgsql.php +++ b/tests/comparisons/Migration/pgsql/test_snapshot_not_empty_pgsql.php @@ -9,7 +9,7 @@ class TestSnapshotNotEmptyPgsql extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -47,14 +47,14 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -83,14 +83,14 @@ public function up(): void 'limit' => 100, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -184,14 +184,14 @@ public function up(): void 'limit' => 10, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -251,7 +251,7 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('highlighted_time', 'timestamp', [ + ->addColumn('highlighted_time', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -289,14 +289,14 @@ public function up(): void 'limit' => 256, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('updated', 'timestamp', [ + ->addColumn('updated', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -349,7 +349,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/pgsql/test_snapshot_plugin_blog_pgsql.php b/tests/comparisons/Migration/pgsql/test_snapshot_plugin_blog_pgsql.php index faa1ec7b9..2541ce034 100644 --- a/tests/comparisons/Migration/pgsql/test_snapshot_plugin_blog_pgsql.php +++ b/tests/comparisons/Migration/pgsql/test_snapshot_plugin_blog_pgsql.php @@ -9,7 +9,7 @@ class TestSnapshotPluginBlogPgsql extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -47,14 +47,14 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -83,14 +83,14 @@ public function up(): void 'limit' => 100, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -184,14 +184,14 @@ public function up(): void 'limit' => 10, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -251,7 +251,7 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('highlighted_time', 'timestamp', [ + ->addColumn('highlighted_time', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -289,14 +289,14 @@ public function up(): void 'limit' => 256, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('updated', 'timestamp', [ + ->addColumn('updated', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -349,7 +349,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/pgsql/test_snapshot_with_change_pgsql.php b/tests/comparisons/Migration/pgsql/test_snapshot_with_change_pgsql.php new file mode 100644 index 000000000..1191bd8c9 --- /dev/null +++ b/tests/comparisons/Migration/pgsql/test_snapshot_with_change_pgsql.php @@ -0,0 +1,347 @@ +table('articles') + ->addColumn('title', 'string', [ + 'comment' => 'Article title', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('note', 'string', [ + 'default' => '7.4', + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('counter', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('active', 'boolean', [ + 'default' => false, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('created', 'timestampfractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->addColumn('modified', 'timestampfractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->addIndex( + $this->index('title') + ->setName('articles_title_idx') + ) + ->create(); + + $this->table('categories') + ->addColumn('parent_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('created', 'timestampfractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->addColumn('modified', 'timestampfractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->addIndex( + $this->index('slug') + ->setName('categories_slug_unique') + ->setType('unique') + ) + ->create(); + + $this->table('composite_pks', ['id' => false, 'primary_key' => ['id', 'name']]) + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'default' => '', + 'limit' => 10, + 'null' => false, + ]) + ->create(); + + $this->table('events') + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('description', 'text', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('published', 'string', [ + 'default' => 'N', + 'limit' => 1, + 'null' => true, + ]) + ->create(); + + $this->table('orders') + ->addColumn('product_category', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addIndex( + $this->index([ + 'product_category', + 'product_id', + ]) + ->setName('orders_product_category_idx') + ) + ->create(); + + $this->table('parts') + ->addColumn('name', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('number', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->create(); + + $this->table('products') + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('created', 'timestampfractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->addColumn('modified', 'timestampfractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->addIndex( + $this->index([ + 'id', + 'category_id', + ]) + ->setName('products_category_unique') + ->setType('unique') + ) + ->addIndex( + $this->index('slug') + ->setName('products_slug_unique') + ->setType('unique') + ) + ->addIndex( + $this->index('title') + ->setName('products_title_idx') + ) + ->create(); + + $this->table('special_pks', ['id' => false, 'primary_key' => ['id']]) + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->create(); + + $this->table('special_tags') + ->addColumn('article_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('author_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('tag_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('highlighted', 'boolean', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('highlighted_time', 'timestampfractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->addIndex( + $this->index('article_id') + ->setName('special_tags_article_unique') + ->setType('unique') + ) + ->create(); + + $this->table('texts', ['id' => false]) + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('description', 'text', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->create(); + + $this->table('users') + ->addColumn('username', 'string', [ + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('password', 'string', [ + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('created', 'timestampfractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->addColumn('updated', 'timestampfractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->create(); + + $this->table('articles') + ->addForeignKey( + $this->foreignKey('category_id') + ->setReferencedTable('categories') + ->setReferencedColumns('id') + ->setOnDelete('NO_ACTION') + ->setOnUpdate('NO_ACTION') + ->setName('articles_category_fk') + ) + ->update(); + + $this->table('orders') + ->addForeignKey( + $this->foreignKey([ + 'product_category', + 'product_id', + ]) + ->setReferencedTable('products') + ->setReferencedColumns([ + 'category_id', + 'id', + ]) + ->setOnDelete('CASCADE') + ->setOnUpdate('CASCADE') + ->setName('orders_product_fk') + ) + ->update(); + + $this->table('products') + ->addForeignKey( + $this->foreignKey('category_id') + ->setReferencedTable('categories') + ->setReferencedColumns('id') + ->setOnDelete('CASCADE') + ->setOnUpdate('CASCADE') + ->setName('products_category_fk') + ) + ->update(); + } +} diff --git a/tests/comparisons/Migration/sqlite/test_snapshot_auto_id_disabled_sqlite.php b/tests/comparisons/Migration/sqlite/test_snapshot_auto_id_disabled_sqlite.php index 1e89cf83b..24da5e35f 100644 --- a/tests/comparisons/Migration/sqlite/test_snapshot_auto_id_disabled_sqlite.php +++ b/tests/comparisons/Migration/sqlite/test_snapshot_auto_id_disabled_sqlite.php @@ -11,7 +11,7 @@ class TestSnapshotAutoIdDisabledSqlite extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -390,7 +390,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/sqlite/test_snapshot_not_empty_sqlite.php b/tests/comparisons/Migration/sqlite/test_snapshot_not_empty_sqlite.php index decb7aa80..e292c18e3 100644 --- a/tests/comparisons/Migration/sqlite/test_snapshot_not_empty_sqlite.php +++ b/tests/comparisons/Migration/sqlite/test_snapshot_not_empty_sqlite.php @@ -9,7 +9,7 @@ class TestSnapshotNotEmptySqlite extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -330,7 +330,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/sqlite/test_snapshot_plugin_blog_sqlite.php b/tests/comparisons/Migration/sqlite/test_snapshot_plugin_blog_sqlite.php index ca743b4b7..78ae984e4 100644 --- a/tests/comparisons/Migration/sqlite/test_snapshot_plugin_blog_sqlite.php +++ b/tests/comparisons/Migration/sqlite/test_snapshot_plugin_blog_sqlite.php @@ -9,7 +9,7 @@ class TestSnapshotPluginBlogSqlite extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -330,7 +330,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/sqlite/test_snapshot_with_change_sqlite.php b/tests/comparisons/Migration/sqlite/test_snapshot_with_change_sqlite.php new file mode 100644 index 000000000..f1fbd6eec --- /dev/null +++ b/tests/comparisons/Migration/sqlite/test_snapshot_with_change_sqlite.php @@ -0,0 +1,328 @@ +table('articles') + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => true, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => true, + ]) + ->addColumn('note', 'string', [ + 'default' => '7.4', + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('counter', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => true, + ]) + ->addColumn('active', 'boolean', [ + 'default' => false, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addIndex( + $this->index('title') + ->setName('articles_title_idx') + ) + ->create(); + + $this->table('categories') + ->addColumn('parent_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => true, + ]) + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addIndex( + $this->index('slug') + ->setName('categories_slug_unique') + ->setType('unique') + ) + ->create(); + + $this->table('composite_pks', ['id' => false, 'primary_key' => ['id', 'name']]) + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'default' => '', + 'limit' => 10, + 'null' => false, + ]) + ->create(); + + $this->table('events') + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('description', 'text', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('published', 'string', [ + 'default' => 'N', + 'limit' => 1, + 'null' => true, + ]) + ->create(); + + $this->table('orders') + ->addColumn('product_category', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => false, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => false, + ]) + ->addIndex( + $this->index([ + 'product_category', + 'product_id', + ]) + ->setName('orders_product_category_idx') + ) + ->create(); + + $this->table('parts') + ->addColumn('name', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('number', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->create(); + + $this->table('products') + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addIndex( + $this->index([ + 'category_id', + 'id', + ]) + ->setName('products_category_unique') + ->setType('unique') + ) + ->addIndex( + $this->index('slug') + ->setName('products_slug_unique') + ->setType('unique') + ) + ->addIndex( + $this->index('title') + ->setName('products_title_idx') + ) + ->create(); + + $this->table('special_pks', ['id' => false, 'primary_key' => ['id']]) + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->create(); + + $this->table('special_tags') + ->addColumn('article_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => false, + ]) + ->addColumn('author_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => true, + ]) + ->addColumn('tag_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => false, + ]) + ->addColumn('highlighted', 'boolean', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('highlighted_time', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addIndex( + $this->index('article_id') + ->setName('special_tags_article_unique') + ->setType('unique') + ) + ->create(); + + $this->table('texts', ['id' => false]) + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('description', 'text', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->create(); + + $this->table('users') + ->addColumn('username', 'string', [ + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('password', 'string', [ + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('updated', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->create(); + + $this->table('articles') + ->addForeignKey( + $this->foreignKey('category_id') + ->setReferencedTable('categories') + ->setReferencedColumns('id') + ->setOnDelete('NO_ACTION') + ->setOnUpdate('NO_ACTION') + ->setName('articles_category_fk') + ) + ->update(); + + $this->table('orders') + ->addForeignKey( + $this->foreignKey([ + 'product_category', + 'product_id', + ]) + ->setReferencedTable('products') + ->setReferencedColumns([ + 'category_id', + 'id', + ]) + ->setOnDelete('CASCADE') + ->setOnUpdate('CASCADE') + ->setName('orders_product_fk') + ) + ->update(); + + $this->table('products') + ->addForeignKey( + $this->foreignKey('category_id') + ->setReferencedTable('categories') + ->setReferencedColumns('id') + ->setOnDelete('CASCADE') + ->setOnUpdate('CASCADE') + ->setName('products_category_fk') + ) + ->update(); + } +} diff --git a/tests/comparisons/Migration/sqlserver/test_snapshot_auto_id_disabled_sqlserver.php b/tests/comparisons/Migration/sqlserver/test_snapshot_auto_id_disabled_sqlserver.php index c9a962979..347b5cdd5 100644 --- a/tests/comparisons/Migration/sqlserver/test_snapshot_auto_id_disabled_sqlserver.php +++ b/tests/comparisons/Migration/sqlserver/test_snapshot_auto_id_disabled_sqlserver.php @@ -11,7 +11,7 @@ class TestSnapshotAutoIdDisabledSqlserver extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -58,14 +58,14 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 7, 'scale' => 7, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -103,14 +103,14 @@ public function up(): void 'limit' => 100, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 7, 'scale' => 7, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -240,14 +240,14 @@ public function up(): void 'limit' => 10, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 7, 'scale' => 7, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -316,7 +316,7 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('highlighted_time', 'timestamp', [ + ->addColumn('highlighted_time', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -365,14 +365,14 @@ public function up(): void 'limit' => 256, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 7, 'scale' => 7, ]) - ->addColumn('updated', 'timestamp', [ + ->addColumn('updated', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -425,7 +425,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/sqlserver/test_snapshot_not_empty_sqlserver.php b/tests/comparisons/Migration/sqlserver/test_snapshot_not_empty_sqlserver.php index 6a1b392c6..e0aad74e8 100644 --- a/tests/comparisons/Migration/sqlserver/test_snapshot_not_empty_sqlserver.php +++ b/tests/comparisons/Migration/sqlserver/test_snapshot_not_empty_sqlserver.php @@ -9,7 +9,7 @@ class TestSnapshotNotEmptySqlserver extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -49,14 +49,14 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 7, 'scale' => 7, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -87,14 +87,14 @@ public function up(): void 'limit' => 100, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 7, 'scale' => 7, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -195,14 +195,14 @@ public function up(): void 'limit' => 10, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 7, 'scale' => 7, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -263,7 +263,7 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('highlighted_time', 'timestamp', [ + ->addColumn('highlighted_time', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -305,14 +305,14 @@ public function up(): void 'limit' => 256, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 7, 'scale' => 7, ]) - ->addColumn('updated', 'timestamp', [ + ->addColumn('updated', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -365,7 +365,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/sqlserver/test_snapshot_plugin_blog_sqlserver.php b/tests/comparisons/Migration/sqlserver/test_snapshot_plugin_blog_sqlserver.php index d13b24e9a..c8d0e8dbf 100644 --- a/tests/comparisons/Migration/sqlserver/test_snapshot_plugin_blog_sqlserver.php +++ b/tests/comparisons/Migration/sqlserver/test_snapshot_plugin_blog_sqlserver.php @@ -9,7 +9,7 @@ class TestSnapshotPluginBlogSqlserver extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -49,14 +49,14 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 7, 'scale' => 7, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -87,14 +87,14 @@ public function up(): void 'limit' => 100, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 7, 'scale' => 7, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -195,14 +195,14 @@ public function up(): void 'limit' => 10, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 7, 'scale' => 7, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -263,7 +263,7 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('highlighted_time', 'timestamp', [ + ->addColumn('highlighted_time', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -305,14 +305,14 @@ public function up(): void 'limit' => 256, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 7, 'scale' => 7, ]) - ->addColumn('updated', 'timestamp', [ + ->addColumn('updated', 'datetimefractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -365,7 +365,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/sqlserver/test_snapshot_with_change_sqlserver.php b/tests/comparisons/Migration/sqlserver/test_snapshot_with_change_sqlserver.php new file mode 100644 index 000000000..36ef3a9b1 --- /dev/null +++ b/tests/comparisons/Migration/sqlserver/test_snapshot_with_change_sqlserver.php @@ -0,0 +1,363 @@ +table('articles') + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'comment' => 'Article title', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('note', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => '7.4', + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('counter', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('active', 'boolean', [ + 'default' => false, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('created', 'datetimefractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('modified', 'datetimefractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + $this->index('title') + ->setName('articles_title_idx') + ) + ->create(); + + $this->table('categories') + ->addColumn('parent_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('created', 'datetimefractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('modified', 'datetimefractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + $this->index('slug') + ->setName('categories_slug_unique') + ->setType('unique') + ) + ->create(); + + $this->table('composite_pks', ['id' => false, 'primary_key' => ['id', 'name']]) + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => '', + 'limit' => 10, + 'null' => false, + ]) + ->create(); + + $this->table('events') + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('description', 'text', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('published', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => 'N', + 'limit' => 1, + 'null' => true, + ]) + ->create(); + + $this->table('orders') + ->addColumn('product_category', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addIndex( + $this->index([ + 'product_category', + 'product_id', + ]) + ->setName('orders_product_category_idx') + ) + ->create(); + + $this->table('parts') + ->addColumn('name', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('number', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->create(); + + $this->table('products') + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('created', 'datetimefractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('modified', 'datetimefractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + $this->index([ + 'category_id', + 'id', + ]) + ->setName('products_category_unique') + ->setType('unique') + ) + ->addIndex( + $this->index('slug') + ->setName('products_slug_unique') + ->setType('unique') + ) + ->addIndex( + $this->index('title') + ->setName('products_title_idx') + ) + ->create(); + + $this->table('special_pks', ['id' => false, 'primary_key' => ['id']]) + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->create(); + + $this->table('special_tags') + ->addColumn('article_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('author_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => true, + ]) + ->addColumn('tag_id', 'integer', [ + 'default' => null, + 'limit' => 10, + 'null' => false, + ]) + ->addColumn('highlighted', 'boolean', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('highlighted_time', 'datetimefractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addIndex( + $this->index('article_id') + ->setName('special_tags_article_unique') + ->setType('unique') + ) + ->create(); + + $this->table('texts', ['id' => false]) + ->addColumn('title', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('description', 'text', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->create(); + + $this->table('users') + ->addColumn('username', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('password', 'string', [ + 'collation' => 'SQL_Latin1_General_CP1_CI_AS', + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('created', 'datetimefractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->addColumn('updated', 'datetimefractional', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 7, + 'scale' => 7, + ]) + ->create(); + + $this->table('articles') + ->addForeignKey( + $this->foreignKey('category_id') + ->setReferencedTable('categories') + ->setReferencedColumns('id') + ->setOnDelete('NO_ACTION') + ->setOnUpdate('NO_ACTION') + ->setName('articles_category_fk') + ) + ->update(); + + $this->table('orders') + ->addForeignKey( + $this->foreignKey([ + 'product_category', + 'product_id', + ]) + ->setReferencedTable('products') + ->setReferencedColumns([ + 'category_id', + 'id', + ]) + ->setOnDelete('CASCADE') + ->setOnUpdate('CASCADE') + ->setName('orders_product_fk') + ) + ->update(); + + $this->table('products') + ->addForeignKey( + $this->foreignKey('category_id') + ->setReferencedTable('categories') + ->setReferencedColumns('id') + ->setOnDelete('CASCADE') + ->setOnUpdate('CASCADE') + ->setName('products_category_fk') + ) + ->update(); + } +} diff --git a/tests/comparisons/Migration/testAddFieldWithReference.php b/tests/comparisons/Migration/testAddFieldWithReference.php index 02991e218..b50ad2f92 100644 --- a/tests/comparisons/Migration/testAddFieldWithReference.php +++ b/tests/comparisons/Migration/testAddFieldWithReference.php @@ -9,7 +9,7 @@ class AddCategoryIdToProducts extends BaseMigration * Change Method. * * More information on this method is available here: - * https://book.cakephp.org/migrations/4/en/migrations.html#the-change-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-change-method * * @return void */ diff --git a/tests/comparisons/Migration/testCreate.php b/tests/comparisons/Migration/testCreate.php index 6aee61547..39bc88392 100644 --- a/tests/comparisons/Migration/testCreate.php +++ b/tests/comparisons/Migration/testCreate.php @@ -9,7 +9,7 @@ class CreateUsers extends BaseMigration * Change Method. * * More information on this method is available here: - * https://book.cakephp.org/migrations/4/en/migrations.html#the-change-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-change-method * * @return void */ diff --git a/tests/comparisons/Migration/testCreateDatetime.php b/tests/comparisons/Migration/testCreateDatetime.php index e9d1da0f0..0547f6851 100644 --- a/tests/comparisons/Migration/testCreateDatetime.php +++ b/tests/comparisons/Migration/testCreateDatetime.php @@ -9,7 +9,7 @@ class CreateUsers extends BaseMigration * Change Method. * * More information on this method is available here: - * https://book.cakephp.org/migrations/4/en/migrations.html#the-change-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-change-method * * @return void */ diff --git a/tests/comparisons/Migration/testCreateDropMigration.php b/tests/comparisons/Migration/testCreateDropMigration.php index c9a567d31..a7550b664 100644 --- a/tests/comparisons/Migration/testCreateDropMigration.php +++ b/tests/comparisons/Migration/testCreateDropMigration.php @@ -9,7 +9,7 @@ class DropUsers extends BaseMigration * Change Method. * * More information on this method is available here: - * https://book.cakephp.org/migrations/4/en/migrations.html#the-change-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-change-method * * @return void */ diff --git a/tests/comparisons/Migration/testCreateFieldLength.php b/tests/comparisons/Migration/testCreateFieldLength.php index ee3e28824..43220d035 100644 --- a/tests/comparisons/Migration/testCreateFieldLength.php +++ b/tests/comparisons/Migration/testCreateFieldLength.php @@ -9,7 +9,7 @@ class CreateUsers extends BaseMigration * Change Method. * * More information on this method is available here: - * https://book.cakephp.org/migrations/4/en/migrations.html#the-change-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-change-method * * @return void */ diff --git a/tests/comparisons/Migration/testCreatePrimaryKey.php b/tests/comparisons/Migration/testCreatePrimaryKey.php index d3c64aaa5..44efb2631 100644 --- a/tests/comparisons/Migration/testCreatePrimaryKey.php +++ b/tests/comparisons/Migration/testCreatePrimaryKey.php @@ -11,7 +11,7 @@ class CreateUsers extends BaseMigration * Change Method. * * More information on this method is available here: - * https://book.cakephp.org/migrations/4/en/migrations.html#the-change-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-change-method * * @return void */ diff --git a/tests/comparisons/Migration/testCreatePrimaryKeyUuid.php b/tests/comparisons/Migration/testCreatePrimaryKeyUuid.php index c00d1fe5d..de8c21792 100644 --- a/tests/comparisons/Migration/testCreatePrimaryKeyUuid.php +++ b/tests/comparisons/Migration/testCreatePrimaryKeyUuid.php @@ -11,7 +11,7 @@ class CreateUsers extends BaseMigration * Change Method. * * More information on this method is available here: - * https://book.cakephp.org/migrations/4/en/migrations.html#the-change-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-change-method * * @return void */ diff --git a/tests/comparisons/Migration/testCreateWithReferences.php b/tests/comparisons/Migration/testCreateWithReferences.php index 6b31569a5..11c01ca2b 100644 --- a/tests/comparisons/Migration/testCreateWithReferences.php +++ b/tests/comparisons/Migration/testCreateWithReferences.php @@ -9,7 +9,7 @@ class CreatePosts extends BaseMigration * Change Method. * * More information on this method is available here: - * https://book.cakephp.org/migrations/4/en/migrations.html#the-change-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-change-method * * @return void */ diff --git a/tests/comparisons/Migration/testCreateWithReferencesCustomTable.php b/tests/comparisons/Migration/testCreateWithReferencesCustomTable.php index b60934d63..a4829f3c2 100644 --- a/tests/comparisons/Migration/testCreateWithReferencesCustomTable.php +++ b/tests/comparisons/Migration/testCreateWithReferencesCustomTable.php @@ -9,7 +9,7 @@ class CreateArticles extends BaseMigration * Change Method. * * More information on this method is available here: - * https://book.cakephp.org/migrations/4/en/migrations.html#the-change-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-change-method * * @return void */ diff --git a/tests/comparisons/Migration/testNoContents.php b/tests/comparisons/Migration/testNoContents.php index e22fc6f86..19c2524b5 100644 --- a/tests/comparisons/Migration/testNoContents.php +++ b/tests/comparisons/Migration/testNoContents.php @@ -9,11 +9,12 @@ class NoContents extends BaseMigration * Change Method. * * More information on this method is available here: - * https://book.cakephp.org/migrations/4/en/migrations.html#the-change-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-change-method * * @return void */ public function change(): void { + } } diff --git a/tests/comparisons/Migration/test_snapshot_auto_id_disabled.php b/tests/comparisons/Migration/test_snapshot_auto_id_disabled.php index 5154610fe..9024c29ad 100644 --- a/tests/comparisons/Migration/test_snapshot_auto_id_disabled.php +++ b/tests/comparisons/Migration/test_snapshot_auto_id_disabled.php @@ -11,7 +11,7 @@ class TestSnapshotAutoIdDisabled extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -415,7 +415,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/test_snapshot_not_empty.php b/tests/comparisons/Migration/test_snapshot_not_empty.php index 78a767bf2..1a277d711 100644 --- a/tests/comparisons/Migration/test_snapshot_not_empty.php +++ b/tests/comparisons/Migration/test_snapshot_not_empty.php @@ -9,7 +9,7 @@ class TestSnapshotNotEmpty extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -347,7 +347,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/test_snapshot_plugin_blog.php b/tests/comparisons/Migration/test_snapshot_plugin_blog.php index 80aecd06e..20dd3fba9 100644 --- a/tests/comparisons/Migration/test_snapshot_plugin_blog.php +++ b/tests/comparisons/Migration/test_snapshot_plugin_blog.php @@ -9,7 +9,7 @@ class TestSnapshotPluginBlog extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -347,7 +347,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/test_snapshot_postgres_timestamp_tz_pgsql.php b/tests/comparisons/Migration/test_snapshot_postgres_timestamp_tz_pgsql.php index 12842121b..51b0002a4 100644 --- a/tests/comparisons/Migration/test_snapshot_postgres_timestamp_tz_pgsql.php +++ b/tests/comparisons/Migration/test_snapshot_postgres_timestamp_tz_pgsql.php @@ -9,7 +9,7 @@ class TestSnapshotPostgresTimestampTzPgsql extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -47,14 +47,14 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -83,14 +83,14 @@ public function up(): void 'limit' => 100, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -194,14 +194,14 @@ public function up(): void 'limit' => 10, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('modified', 'timestamp', [ + ->addColumn('modified', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -261,7 +261,7 @@ public function up(): void 'limit' => null, 'null' => true, ]) - ->addColumn('highlighted_time', 'timestamp', [ + ->addColumn('highlighted_time', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -299,14 +299,14 @@ public function up(): void 'limit' => 256, 'null' => true, ]) - ->addColumn('created', 'timestamp', [ + ->addColumn('created', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, 'precision' => 6, 'scale' => 6, ]) - ->addColumn('updated', 'timestamp', [ + ->addColumn('updated', 'timestampfractional', [ 'default' => null, 'limit' => null, 'null' => true, @@ -359,7 +359,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/test_snapshot_with_auto_id_compatible_signed_primary_keys.php b/tests/comparisons/Migration/test_snapshot_with_auto_id_compatible_signed_primary_keys.php index 8a099f25c..df2ea8ba0 100644 --- a/tests/comparisons/Migration/test_snapshot_with_auto_id_compatible_signed_primary_keys.php +++ b/tests/comparisons/Migration/test_snapshot_with_auto_id_compatible_signed_primary_keys.php @@ -11,7 +11,7 @@ class TestSnapshotWithAutoIdCompatibleSignedPrimaryKeys extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -142,7 +142,6 @@ public function up(): void 'default' => null, 'limit' => null, 'null' => false, - 'signed' => true, ]) ->addPrimaryKey(['id']) ->addColumn('title', 'string', [ @@ -415,7 +414,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/test_snapshot_with_auto_id_incompatible_signed_primary_keys.php b/tests/comparisons/Migration/test_snapshot_with_auto_id_incompatible_signed_primary_keys.php index ce5b10020..11fadb44f 100644 --- a/tests/comparisons/Migration/test_snapshot_with_auto_id_incompatible_signed_primary_keys.php +++ b/tests/comparisons/Migration/test_snapshot_with_auto_id_incompatible_signed_primary_keys.php @@ -11,7 +11,7 @@ class TestSnapshotWithAutoIdIncompatibleSignedPrimaryKeys extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -142,7 +142,6 @@ public function up(): void 'default' => null, 'limit' => null, 'null' => false, - 'signed' => true, ]) ->addPrimaryKey(['id']) ->addColumn('title', 'string', [ @@ -415,7 +414,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/test_snapshot_with_auto_id_incompatible_unsigned_primary_keys.php b/tests/comparisons/Migration/test_snapshot_with_auto_id_incompatible_unsigned_primary_keys.php index da51a849d..93570e1ec 100644 --- a/tests/comparisons/Migration/test_snapshot_with_auto_id_incompatible_unsigned_primary_keys.php +++ b/tests/comparisons/Migration/test_snapshot_with_auto_id_incompatible_unsigned_primary_keys.php @@ -11,7 +11,7 @@ class TestSnapshotWithAutoIdIncompatibleUnsignedPrimaryKeys extends BaseMigratio * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -415,7 +415,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Migration/test_snapshot_with_change.php b/tests/comparisons/Migration/test_snapshot_with_change.php new file mode 100644 index 000000000..ec5f12a64 --- /dev/null +++ b/tests/comparisons/Migration/test_snapshot_with_change.php @@ -0,0 +1,345 @@ +table('articles') + ->addColumn('title', 'string', [ + 'comment' => 'Article title', + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'signed' => false, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'signed' => false, + ]) + ->addColumn('note', 'string', [ + 'default' => '7.4', + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('counter', 'integer', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'signed' => false, + ]) + ->addColumn('active', 'boolean', [ + 'default' => false, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addIndex( + $this->index('category_id') + ->setName('articles_category_fk') + ) + ->addIndex( + $this->index('title') + ->setName('articles_title_idx') + ) + ->create(); + + $this->table('categories') + ->addColumn('parent_id', 'integer', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'signed' => false, + ]) + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addIndex( + $this->index('slug') + ->setName('categories_slug_unique') + ->setType('unique') + ) + ->create(); + + $this->table('composite_pks', ['id' => false, 'primary_key' => ['id', 'name']]) + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'default' => '', + 'limit' => 10, + 'null' => false, + ]) + ->create(); + + $this->table('events') + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('description', 'text', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('published', 'string', [ + 'default' => 'N', + 'limit' => 1, + 'null' => true, + ]) + ->create(); + + $this->table('orders') + ->addColumn('product_category', 'integer', [ + 'default' => null, + 'limit' => null, + 'null' => false, + 'signed' => false, + ]) + ->addColumn('product_id', 'integer', [ + 'default' => null, + 'limit' => null, + 'null' => false, + 'signed' => false, + ]) + ->addIndex( + $this->index([ + 'product_category', + 'product_id', + ]) + ->setName('orders_product_category_idx') + ) + ->create(); + + $this->table('parts') + ->addColumn('name', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('number', 'integer', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'signed' => false, + ]) + ->create(); + + $this->table('products') + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('slug', 'string', [ + 'default' => null, + 'limit' => 100, + 'null' => true, + ]) + ->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'signed' => false, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('modified', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addIndex( + $this->index('slug') + ->setName('products_slug_unique') + ->setType('unique') + ) + ->addIndex( + $this->index([ + 'category_id', + 'id', + ]) + ->setName('products_category_unique') + ->setType('unique') + ) + ->addIndex( + $this->index('title') + ->setName('products_title_idx') + ->setType('fulltext') + ) + ->create(); + + $this->table('special_pks', ['id' => false, 'primary_key' => ['id']]) + ->addColumn('id', 'uuid', [ + 'default' => 'a4950df3-515f-474c-be4c-6a027c1957e7', + 'limit' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->create(); + + $this->table('special_tags') + ->addColumn('article_id', 'integer', [ + 'default' => null, + 'limit' => null, + 'null' => false, + 'signed' => false, + ]) + ->addColumn('author_id', 'integer', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'signed' => false, + ]) + ->addColumn('tag_id', 'integer', [ + 'default' => null, + 'limit' => null, + 'null' => false, + 'signed' => false, + ]) + ->addColumn('highlighted', 'boolean', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('highlighted_time', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addIndex( + $this->index('article_id') + ->setName('special_tags_article_unique') + ->setType('unique') + ) + ->create(); + + $this->table('texts', ['id' => false]) + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]) + ->addColumn('description', 'text', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->create(); + + $this->table('users') + ->addColumn('username', 'string', [ + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('password', 'string', [ + 'default' => null, + 'limit' => 256, + 'null' => true, + ]) + ->addColumn('created', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->addColumn('updated', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->create(); + + $this->table('articles') + ->addForeignKey( + $this->foreignKey('category_id') + ->setReferencedTable('categories') + ->setReferencedColumns('id') + ->setOnDelete('NO_ACTION') + ->setOnUpdate('NO_ACTION') + ->setName('articles_category_fk') + ) + ->update(); + + $this->table('orders') + ->addForeignKey( + $this->foreignKey([ + 'product_category', + 'product_id', + ]) + ->setReferencedTable('products') + ->setReferencedColumns([ + 'category_id', + 'id', + ]) + ->setOnDelete('CASCADE') + ->setOnUpdate('CASCADE') + ->setName('orders_product_fk') + ) + ->update(); + + $this->table('products') + ->addForeignKey( + $this->foreignKey('category_id') + ->setReferencedTable('categories') + ->setReferencedColumns('id') + ->setOnDelete('CASCADE') + ->setOnUpdate('CASCADE') + ->setName('products_category_fk') + ) + ->update(); + } +} diff --git a/tests/comparisons/Migration/test_snapshot_with_non_default_collation.php b/tests/comparisons/Migration/test_snapshot_with_non_default_collation.php index fe4cd43bc..45cad82a0 100644 --- a/tests/comparisons/Migration/test_snapshot_with_non_default_collation.php +++ b/tests/comparisons/Migration/test_snapshot_with_non_default_collation.php @@ -9,7 +9,7 @@ class TestSnapshotWithNonDefaultCollation extends BaseMigration * Up Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-up-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-up-method * * @return void */ @@ -119,7 +119,7 @@ public function up(): void $this->table('events') ->addColumn('title', 'string', [ - 'collation' => 'utf8mb3_hungarian_ci', + 'collation' => 'utf8_hungarian_ci', 'default' => null, 'limit' => 255, 'null' => true, @@ -348,7 +348,7 @@ public function up(): void * Down Method. * * More information on this method is available here: - * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * https://book.cakephp.org/migrations/5/en/migrations.html#the-down-method * * @return void */ diff --git a/tests/comparisons/Seeds/pgsql/testWithData.php b/tests/comparisons/Seeds/pgsql/testWithData.php index 63047b5bc..272d14ecd 100644 --- a/tests/comparisons/Seeds/pgsql/testWithData.php +++ b/tests/comparisons/Seeds/pgsql/testWithData.php @@ -14,7 +14,7 @@ class EventsSeed extends BaseSeed * Write your database seeder using this method. * * More information on writing seeds is available here: - * https://book.cakephp.org/migrations/4/en/seeding.html + * https://book.cakephp.org/migrations/5/en/seeding.html * * @return void */ diff --git a/tests/comparisons/Seeds/pgsql/testWithDataAndLimit.php b/tests/comparisons/Seeds/pgsql/testWithDataAndLimit.php index f5346af81..45fda5df6 100644 --- a/tests/comparisons/Seeds/pgsql/testWithDataAndLimit.php +++ b/tests/comparisons/Seeds/pgsql/testWithDataAndLimit.php @@ -14,7 +14,7 @@ class EventsSeed extends BaseSeed * Write your database seeder using this method. * * More information on writing seeds is available here: - * https://book.cakephp.org/migrations/4/en/seeding.html + * https://book.cakephp.org/migrations/5/en/seeding.html * * @return void */ diff --git a/tests/comparisons/Seeds/php81/testWithData.php b/tests/comparisons/Seeds/php81/testWithData.php index 63047b5bc..272d14ecd 100644 --- a/tests/comparisons/Seeds/php81/testWithData.php +++ b/tests/comparisons/Seeds/php81/testWithData.php @@ -14,7 +14,7 @@ class EventsSeed extends BaseSeed * Write your database seeder using this method. * * More information on writing seeds is available here: - * https://book.cakephp.org/migrations/4/en/seeding.html + * https://book.cakephp.org/migrations/5/en/seeding.html * * @return void */ diff --git a/tests/comparisons/Seeds/php81/testWithDataAndLimit.php b/tests/comparisons/Seeds/php81/testWithDataAndLimit.php index f5346af81..45fda5df6 100644 --- a/tests/comparisons/Seeds/php81/testWithDataAndLimit.php +++ b/tests/comparisons/Seeds/php81/testWithDataAndLimit.php @@ -14,7 +14,7 @@ class EventsSeed extends BaseSeed * Write your database seeder using this method. * * More information on writing seeds is available here: - * https://book.cakephp.org/migrations/4/en/seeding.html + * https://book.cakephp.org/migrations/5/en/seeding.html * * @return void */ diff --git a/tests/comparisons/Seeds/sqlserver/testWithData.php b/tests/comparisons/Seeds/sqlserver/testWithData.php index ff9d221bc..6cd3d80d6 100644 --- a/tests/comparisons/Seeds/sqlserver/testWithData.php +++ b/tests/comparisons/Seeds/sqlserver/testWithData.php @@ -14,7 +14,7 @@ class EventsSeed extends BaseSeed * Write your database seeder using this method. * * More information on writing seeds is available here: - * https://book.cakephp.org/migrations/4/en/seeding.html + * https://book.cakephp.org/migrations/5/en/seeding.html * * @return void */ diff --git a/tests/comparisons/Seeds/sqlserver/testWithDataAndLimit.php b/tests/comparisons/Seeds/sqlserver/testWithDataAndLimit.php index a5c8fec77..3da226273 100644 --- a/tests/comparisons/Seeds/sqlserver/testWithDataAndLimit.php +++ b/tests/comparisons/Seeds/sqlserver/testWithDataAndLimit.php @@ -14,7 +14,7 @@ class EventsSeed extends BaseSeed * Write your database seeder using this method. * * More information on writing seeds is available here: - * https://book.cakephp.org/migrations/4/en/seeding.html + * https://book.cakephp.org/migrations/5/en/seeding.html * * @return void */ diff --git a/tests/comparisons/Seeds/testBasicBaking.php b/tests/comparisons/Seeds/testBasicBaking.php index 086dd4512..ce5dc9915 100644 --- a/tests/comparisons/Seeds/testBasicBaking.php +++ b/tests/comparisons/Seeds/testBasicBaking.php @@ -14,7 +14,7 @@ class ArticlesSeed extends BaseSeed * Write your database seeder using this method. * * More information on writing seeds is available here: - * https://book.cakephp.org/migrations/4/en/seeding.html + * https://book.cakephp.org/migrations/5/en/seeding.html * * @return void */ diff --git a/tests/comparisons/Seeds/testBasicBakingPhinx.php b/tests/comparisons/Seeds/testBasicBakingPhinx.php deleted file mode 100644 index 391a42871..000000000 --- a/tests/comparisons/Seeds/testBasicBakingPhinx.php +++ /dev/null @@ -1,28 +0,0 @@ -table('articles'); - $table->insert($data)->save(); - } -} diff --git a/tests/comparisons/Seeds/testPrettifyArray.php b/tests/comparisons/Seeds/testPrettifyArray.php index 5f48bac73..c5a2978da 100644 --- a/tests/comparisons/Seeds/testPrettifyArray.php +++ b/tests/comparisons/Seeds/testPrettifyArray.php @@ -14,7 +14,7 @@ class TextsSeed extends BaseSeed * Write your database seeder using this method. * * More information on writing seeds is available here: - * https://book.cakephp.org/migrations/4/en/seeding.html + * https://book.cakephp.org/migrations/5/en/seeding.html * * @return void */ diff --git a/tests/comparisons/Seeds/testWithData.php b/tests/comparisons/Seeds/testWithData.php index 812fdcae3..6cd3d80d6 100644 --- a/tests/comparisons/Seeds/testWithData.php +++ b/tests/comparisons/Seeds/testWithData.php @@ -1,12 +1,12 @@ table('users'); + $table = $this->table('test_anonymous'); $table->addColumn('name', 'string', [ 'default' => null, 'limit' => 255, 'null' => false, ]); + $table->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => false, + ]); $table->create(); } -} +}; diff --git a/tests/test_app/config/BaseSeeds/MigrationSeedNumbers.php b/tests/test_app/config/BaseSeeds/MigrationSeedNumbers.php index e778b424b..5558fb593 100644 --- a/tests/test_app/config/BaseSeeds/MigrationSeedNumbers.php +++ b/tests/test_app/config/BaseSeeds/MigrationSeedNumbers.php @@ -3,7 +3,7 @@ use Migrations\BaseSeed; /** - * NumbersSeed seed. + * MigrationSeedNumbers seed. */ class MigrationSeedNumbers extends BaseSeed { @@ -13,7 +13,7 @@ class MigrationSeedNumbers extends BaseSeed * Write your database seeder using this method. * * More information on writing seeders is available here: - * https://book.cakephp.org/phinx/0/en/seeding.html + * https://book.cakephp.org/migrations/5/en/seeding.html */ public function run(): void { diff --git a/tests/test_app/config/CallSeeds/DatabaseSeed.php b/tests/test_app/config/CallSeeds/DatabaseSeed.php index 8cc3735b5..e8e69f01d 100644 --- a/tests/test_app/config/CallSeeds/DatabaseSeed.php +++ b/tests/test_app/config/CallSeeds/DatabaseSeed.php @@ -1,11 +1,11 @@ call('NumbersCall'); + $this->call('Letters'); + } +} diff --git a/tests/test_app/config/DropColumnFkIndexRegression/20190928205056_first_fk_index_migration.php b/tests/test_app/config/DropColumnFkIndexRegression/20190928205056_first_fk_index_migration.php index dcc593e7d..a24c27de9 100644 --- a/tests/test_app/config/DropColumnFkIndexRegression/20190928205056_first_fk_index_migration.php +++ b/tests/test_app/config/DropColumnFkIndexRegression/20190928205056_first_fk_index_migration.php @@ -1,8 +1,8 @@ false, 'limit' => 20, 'identity' => true, + 'signed' => false, ]) ->create(); @@ -35,6 +36,7 @@ public function up() 'null' => false, 'limit' => 20, 'identity' => true, + 'signed' => false, ]) ->create(); @@ -51,11 +53,13 @@ public function up() 'null' => false, 'limit' => 20, 'identity' => true, + 'signed' => false, ]) ->addColumn('table2_id', 'integer', [ 'null' => true, 'limit' => 20, 'after' => 'id', + 'signed' => false, ]) ->addIndex(['table2_id'], [ 'name' => 'table1_table2_id', @@ -69,6 +73,7 @@ public function up() ->addColumn('table3_id', 'integer', [ 'null' => true, 'limit' => 20, + 'signed' => false, ]) ->addIndex(['table3_id'], [ 'name' => 'table1_table3_id', diff --git a/tests/test_app/config/DropColumnFkIndexRegression/20190928205060_second_fk_index_migration.php b/tests/test_app/config/DropColumnFkIndexRegression/20190928205060_second_fk_index_migration.php index 119aa8869..f6c7cb22e 100644 --- a/tests/test_app/config/DropColumnFkIndexRegression/20190928205060_second_fk_index_migration.php +++ b/tests/test_app/config/DropColumnFkIndexRegression/20190928205060_second_fk_index_migration.php @@ -1,8 +1,8 @@ 'dependency_test', + 'created' => date('Y-m-d H:i:s'), + ], + ]; + + $posts = $this->table('posts'); + $posts->insert($data)->save(); + } + + public function getDependencies(): array + { + return [ + 'User', // Short name without 'Seeder' suffix + 'G', // Short name without 'Seeder' suffix + ]; + } +} diff --git a/tests/test_app/config/ManagerSeeds/UserSeeder.php b/tests/test_app/config/ManagerSeeds/UserSeeder.php index b2f22921e..a659d5de7 100644 --- a/tests/test_app/config/ManagerSeeds/UserSeeder.php +++ b/tests/test_app/config/ManagerSeeds/UserSeeder.php @@ -1,9 +1,9 @@ table('articles'); + $table = $this->table('articles', ['signed' => false]); $table ->addColumn('title', 'string', [ 'default' => null, diff --git a/tests/test_app/config/MigrationsDiffDefault/20160128183623_AlterArticlesDefault.php b/tests/test_app/config/MigrationsDiffDefault/20160128183623_AlterArticlesDefault.php index 1a2ad5e36..ca59807bc 100644 --- a/tests/test_app/config/MigrationsDiffDefault/20160128183623_AlterArticlesDefault.php +++ b/tests/test_app/config/MigrationsDiffDefault/20160128183623_AlterArticlesDefault.php @@ -1,14 +1,14 @@ table('users'); + $table = $this->table('users', ['signed' => false]); $table->addColumn('username', 'string', [ 'default' => null, 'limit' => 255, diff --git a/tests/test_app/config/MigrationsDiffDefault/20160128184109_AlterArticlesFkDefault.php b/tests/test_app/config/MigrationsDiffDefault/20160128184109_AlterArticlesFkDefault.php index 69107ba64..a66b5645b 100644 --- a/tests/test_app/config/MigrationsDiffDefault/20160128184109_AlterArticlesFkDefault.php +++ b/tests/test_app/config/MigrationsDiffDefault/20160128184109_AlterArticlesFkDefault.php @@ -1,14 +1,14 @@ table('tags'); + $table = $this->table('tags', ['signed' => false]); $table->addColumn('name', 'string', [ 'default' => null, 'limit' => 255, diff --git a/tests/test_app/config/MigrationsDiffSimple/20151218183450_CreateArticlesSimple.php b/tests/test_app/config/MigrationsDiffSimple/20151218183450_CreateArticlesSimple.php index b02fa0c1f..b36d9de1b 100644 --- a/tests/test_app/config/MigrationsDiffSimple/20151218183450_CreateArticlesSimple.php +++ b/tests/test_app/config/MigrationsDiffSimple/20151218183450_CreateArticlesSimple.php @@ -1,8 +1,8 @@ 'anonymous_store', + ], + [ + 'name' => 'other_store', + ], + ]; + + $table = $this->table('stores'); + $table->insert($data)->save(); + } +}; diff --git a/tests/test_app/config/Seeds/NumbersSeed.php b/tests/test_app/config/Seeds/NumbersSeed.php index faf17b58c..531684c1c 100644 --- a/tests/test_app/config/Seeds/NumbersSeed.php +++ b/tests/test_app/config/Seeds/NumbersSeed.php @@ -1,11 +1,11 @@ 'foo', 'created' => new Date(), - 'modified' => new Date(), + 'updated' => new Date(), ], [ 'name' => 'foo_with_date', 'created' => new DateTime(), - 'modified' => new DateTime(), + 'updated' => new DateTime(), ], ]; diff --git a/tests/test_app/config/ShouldExecute/20201207205056_should_not_execute_migration.php b/tests/test_app/config/ShouldExecute/20201207205056_should_not_execute_migration.php index e90f2ed6a..1e45f604e 100644 --- a/tests/test_app/config/ShouldExecute/20201207205056_should_not_execute_migration.php +++ b/tests/test_app/config/ShouldExecute/20201207205056_should_not_execute_migration.php @@ -1,9 +1,9 @@ table('numbers') + ->insert([ + 'number' => '99', + 'radix' => '10', + ]) + ->save(); + } +} diff --git a/tests/test_app/config/TestsMigrations/20150704160200_create_numbers_table.php b/tests/test_app/config/TestsMigrations/20150704160200_create_numbers_table.php index 9ef60617e..db2314864 100644 --- a/tests/test_app/config/TestsMigrations/20150704160200_create_numbers_table.php +++ b/tests/test_app/config/TestsMigrations/20150704160200_create_numbers_table.php @@ -1,8 +1,8 @@