From 30ac143dc2cd2c25bde6faecc152c11e4bc3f525 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Sun, 14 Dec 2025 00:51:37 -0500 Subject: [PATCH 1/3] Add test capturing the generation of snapshots using `timestamptimezone` This is a cakephp/database abstract type, which is fine with the built-in backend. --- .../BakeMigrationSnapshotCommandTest.php | 29 ++ ...t_snapshot_postgres_timestamp_tz_pgsql.php | 399 ++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 tests/comparisons/Migration/test_snapshot_postgres_timestamp_tz_pgsql.php diff --git a/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php b/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php index 2213fb08..71c1af8f 100644 --- a/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php +++ b/tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php @@ -139,6 +139,35 @@ public function testSnapshotGenerateOnly() $this->assertFalse(file_exists($this->migrationPath . 'schema-dump-test.lock'), 'Lock file should not be created with --generate-only'); } + public function testSnapshotPostgresTimestampTzColumn(): void + { + $this->skipIf(env('DB') !== 'pgsql'); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + $connection->execute( + 'CREATE TABLE IF NOT EXISTS postgres_timestamp_tz (id SERIAL PRIMARY KEY, created TIMESTAMPTZ NOT NULL)', + ); + + $scenario = 'PostgresTimestampTz'; + + $bakeName = $this->getBakeName("TestSnapshot{$scenario}"); + $this->exec("bake migration_snapshot {$bakeName} -c test"); + + $connection->execute('DROP TABLE postgres_timestamp_tz'); + + $generatedMigration = glob($this->migrationPath . "*_TestSnapshot{$scenario}*.php"); + $this->generatedFiles = $generatedMigration; + $this->generatedFiles[] = $this->migrationPath . 'schema-dump-test.lock'; + + $generatedMigration = basename($generatedMigration[0]); + $fileName = pathinfo($generatedMigration, PATHINFO_FILENAME); + $this->assertOutputContains('Marking the migration ' . $fileName . ' as migrated...'); + $this->assertOutputContains('Creating a dump of the new database state...'); + $this->assertNotEmpty($this->generatedFiles); + $this->assertCorrectSnapshot($bakeName, file_get_contents($this->generatedFiles[0])); + } + /** * Test baking a snapshot with the phinx auto-id feature disabled * diff --git a/tests/comparisons/Migration/test_snapshot_postgres_timestamp_tz_pgsql.php b/tests/comparisons/Migration/test_snapshot_postgres_timestamp_tz_pgsql.php new file mode 100644 index 00000000..12842121 --- /dev/null +++ b/tests/comparisons/Migration/test_snapshot_postgres_timestamp_tz_pgsql.php @@ -0,0 +1,399 @@ +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', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->addColumn('modified', 'timestamp', [ + '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', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->addColumn('modified', 'timestamp', [ + '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('postgres_timestamp_tz') + ->addColumn('created', 'timestamptimezone', [ + 'default' => null, + 'limit' => null, + 'null' => false, + 'precision' => 6, + 'scale' => 6, + ]) + ->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', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->addColumn('modified', 'timestamp', [ + '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', 'timestamp', [ + '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', 'timestamp', [ + 'default' => null, + 'limit' => null, + 'null' => true, + 'precision' => 6, + 'scale' => 6, + ]) + ->addColumn('updated', 'timestamp', [ + '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(); + } + + /** + * Down Method. + * + * More information on this method is available here: + * https://book.cakephp.org/phinx/0/en/migrations.html#the-down-method + * + * @return void + */ + public function down(): void + { + $this->table('articles') + ->dropForeignKey( + 'category_id' + )->save(); + + $this->table('orders') + ->dropForeignKey( + [ + 'product_category', + 'product_id', + ] + )->save(); + + $this->table('products') + ->dropForeignKey( + 'category_id' + )->save(); + + $this->table('articles')->drop()->save(); + $this->table('categories')->drop()->save(); + $this->table('composite_pks')->drop()->save(); + $this->table('events')->drop()->save(); + $this->table('orders')->drop()->save(); + $this->table('parts')->drop()->save(); + $this->table('postgres_timestamp_tz')->drop()->save(); + $this->table('products')->drop()->save(); + $this->table('special_pks')->drop()->save(); + $this->table('special_tags')->drop()->save(); + $this->table('texts')->drop()->save(); + $this->table('users')->drop()->save(); + } +} From 9269af86fece33c3200cf2942c97511425392d93 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Sun, 14 Dec 2025 01:15:11 -0500 Subject: [PATCH 2/3] Fix running migrations with timestamptimezone type We generate migrations with this type which is a cakephp/database type. It should be allowed in platforms that support it. --- src/Db/Adapter/PostgresAdapter.php | 1 + tests/TestCase/Migration/ManagerTest.php | 17 ++++++++++++++ ...20151218183450_CreateTimestamptimezone.php | 23 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 tests/test_app/config/PostgresTimestamptimezone/20151218183450_CreateTimestamptimezone.php diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index e77e7a44..53a66c2e 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -43,6 +43,7 @@ class PostgresAdapter extends AbstractAdapter self::PHINX_TYPE_INTERVAL, self::PHINX_TYPE_BINARYUUID, self::PHINX_TYPE_NATIVEUUID, + TableSchema::TYPE_TIMESTAMP_TIMEZONE, ]; private const GIN_INDEX_TYPE = 'gin'; diff --git a/tests/TestCase/Migration/ManagerTest.php b/tests/TestCase/Migration/ManagerTest.php index b7cb7ed4..fb246034 100644 --- a/tests/TestCase/Migration/ManagerTest.php +++ b/tests/TestCase/Migration/ManagerTest.php @@ -2829,6 +2829,23 @@ public function testPostgresFullMigration(): void $this->assertFalse($adapter->hasTable('users')); } + public function testPostgresTimestamptimezone(): void + { + if ($this->getDriverType() !== 'postgres') { + $this->markTestSkipped('Test requires postgres'); + } + $adapter = $this->prepareEnvironment([ + 'migrations' => ROOT . '/config/PostgresTimestamptimezone', + ]); + $adapter->connect(); + // migrate to the latest version + $this->manager->migrate(); + + $this->assertTrue($adapter->hasTable('timestamp_articles')); + + $this->manager->rollback('all'); + } + public function testMigrationWithDropColumnAndForeignKeyAndIndex(): void { if ($this->getDriverType() !== 'mysql') { diff --git a/tests/test_app/config/PostgresTimestamptimezone/20151218183450_CreateTimestamptimezone.php b/tests/test_app/config/PostgresTimestamptimezone/20151218183450_CreateTimestamptimezone.php new file mode 100644 index 00000000..65886009 --- /dev/null +++ b/tests/test_app/config/PostgresTimestamptimezone/20151218183450_CreateTimestamptimezone.php @@ -0,0 +1,23 @@ +table('timestamp_articles'); + $table + ->addColumn('title', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => false, + ]) + ->addColumn('created_at', 'timestamptimezone', [ + 'default' => null, + 'limit' => null, + 'null' => false, + ]); + $table->create(); + } +} From 1788f3df52be32d9feaaa5ad9d4b9dfeac3c1ea7 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Sun, 14 Dec 2025 01:20:46 -0500 Subject: [PATCH 3/3] Make behavior generic as all backends support this type. --- src/Db/Adapter/AbstractAdapter.php | 3 +++ src/Db/Adapter/PostgresAdapter.php | 1 - .../20151218183450_CreateTimestamptimezone.php | 5 +++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index c1339bdb..f78453a6 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -1007,7 +1007,10 @@ public function getColumnTypes(): array 'decimal', 'double', 'datetime', + 'datetimefractional', 'timestamp', + 'timestampfractional', + 'timestamptimezone', 'time', 'date', 'blob', diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index 53a66c2e..e77e7a44 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -43,7 +43,6 @@ class PostgresAdapter extends AbstractAdapter self::PHINX_TYPE_INTERVAL, self::PHINX_TYPE_BINARYUUID, self::PHINX_TYPE_NATIVEUUID, - TableSchema::TYPE_TIMESTAMP_TIMEZONE, ]; private const GIN_INDEX_TYPE = 'gin'; diff --git a/tests/test_app/config/PostgresTimestamptimezone/20151218183450_CreateTimestamptimezone.php b/tests/test_app/config/PostgresTimestamptimezone/20151218183450_CreateTimestamptimezone.php index 65886009..fb322a1c 100644 --- a/tests/test_app/config/PostgresTimestamptimezone/20151218183450_CreateTimestamptimezone.php +++ b/tests/test_app/config/PostgresTimestamptimezone/20151218183450_CreateTimestamptimezone.php @@ -17,6 +17,11 @@ public function change(): void 'default' => null, 'limit' => null, 'null' => false, + ]) + ->addColumn('deleted_at', 'datetimefractional', [ + 'default' => null, + 'limit' => null, + 'null' => false, ]); $table->create(); }