Repository: cakephp/migrations
Pull request #838: Fix up DateTime support for seed bulkinsert().
Author: login: dereuromark
Created: 2025-04-08T00:24:33Z | Merged: 2025-04-19T16:38:20Z

Resolves https://github.com/cakephp/migrations/issues/836

Apparently adding this to bulkinsert()
```php
                    if ($placeholder == '?') {
                        if ($v instanceof DateTime) {
                            $vals[] = $v->toDateTimeString();
                        } elseif ($v instanceof Date) {
                            $vals[] = $v->toDateString();
                        } elseif (is_bool($v)) {
                            $vals[] = $this->castToBool($v);
                        } else {
                            $vals[] = $v;
                        }
                    }
```          
seems to fix it for my local MySql env.


How could a test case look like for this?
And in what class, for what method?


Diff:
diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php
index a5ca28442..1b5ab8cd0 100644
--- a/src/Db/Adapter/AbstractAdapter.php
+++ b/src/Db/Adapter/AbstractAdapter.php
@@ -17,6 +17,8 @@
 use Cake\Database\Query\SelectQuery;
 use Cake\Database\Query\UpdateQuery;
 use Cake\Database\Schema\SchemaDialect;
+use Cake\I18n\Date;
+use Cake\I18n\DateTime;
 use Exception;
 use InvalidArgumentException;
 use Migrations\Config\Config;
@@ -723,7 +725,11 @@ public function bulkinsert(TableMetadata $table, array $rows): void
                         $placeholder = (string)$v;
                     }
                     if ($placeholder == '?') {
-                        if (is_bool($v)) {
+                        if ($v instanceof DateTime) {
+                            $vals[] = $v->toDateTimeString();
+                        } elseif ($v instanceof Date) {
+                            $vals[] = $v->toDateString();
+                        } elseif (is_bool($v)) {
                             $vals[] = $this->castToBool($v);
                         } else {
                             $vals[] = $v;
diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php
index e642a0a63..94039a33a 100644
--- a/src/Db/Adapter/PostgresAdapter.php
+++ b/src/Db/Adapter/PostgresAdapter.php
@@ -9,6 +9,8 @@
 namespace Migrations\Db\Adapter;
 
 use Cake\Database\Connection;
+use Cake\I18n\Date;
+use Cake\I18n\DateTime;
 use InvalidArgumentException;
 use Migrations\Db\AlterInstructions;
 use Migrations\Db\Literal;
@@ -1526,7 +1528,11 @@ public function bulkinsert(Table $table, array $rows): void
                     }
                     $values[] = $placeholder;
                     if ($placeholder == '?') {
-                        if (is_bool($v)) {
+                        if ($v instanceof DateTime) {
+                            $vals[] = $v->toDateTimeString();
+                        } elseif ($v instanceof Date) {
+                            $vals[] = $v->toDateString();
+                        } elseif (is_bool($v)) {
                             $vals[] = $this->castToBool($v);
                         } else {
                             $vals[] = $v;
diff --git a/src/Db/Adapter/SqlserverAdapter.php b/src/Db/Adapter/SqlserverAdapter.php
index 0053a3bf1..a7acc93c2 100644
--- a/src/Db/Adapter/SqlserverAdapter.php
+++ b/src/Db/Adapter/SqlserverAdapter.php
@@ -9,6 +9,8 @@
 namespace Migrations\Db\Adapter;
 
 use BadMethodCallException;
+use Cake\I18n\Date;
+use Cake\I18n\DateTime;
 use InvalidArgumentException;
 use Migrations\Db\AlterInstructions;
 use Migrations\Db\Literal;
@@ -1361,7 +1363,11 @@ public function bulkinsert(TableMetadata $table, array $rows): void
                         $placeholder = (string)$v;
                     }
                     if ($placeholder == '?') {
-                        if (is_bool($v)) {
+                        if ($v instanceof DateTime) {
+                            $vals[] = $v->toDateTimeString();
+                        } elseif ($v instanceof Date) {
+                            $vals[] = $v->toDateString();
+                        } elseif (is_bool($v)) {
                             $vals[] = $this->castToBool($v);
                         } else {
                             $vals[] = $v;
diff --git a/tests/TestCase/Command/SeedCommandTest.php b/tests/TestCase/Command/SeedCommandTest.php
index 8bac092e1..af966e1dd 100644
--- a/tests/TestCase/Command/SeedCommandTest.php
+++ b/tests/TestCase/Command/SeedCommandTest.php
@@ -11,6 +11,8 @@
 use Cake\Event\EventManager;
 use Cake\TestSuite\TestCase;
 use InvalidArgumentException;
+use Phinx\Config\FeatureFlags;
+use ReflectionClass;
 use ReflectionProperty;
 
 class SeedCommandTest extends TestCase
@@ -38,6 +40,13 @@ 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
@@ -141,7 +150,7 @@ public function testSeederBaseSeed(): void
         $this->assertEquals(2, $query->fetchColumn(0));
     }
 
-    public function testSeederImplictAll(): void
+    public function testSeederImplicitAll(): void
     {
         $this->createTables();
         $this->exec('migrations seed -c test');
@@ -192,4 +201,68 @@ public function testSeederSourceNotFound(): void
 
         $this->exec('migrations seed -c test --source NotThere --seed 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:</info> <comment>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->assertExitSuccess();
+        $this->assertOutputContains('StoresSeed:</info> <comment>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']);
+    }
 }
diff --git a/tests/test_app/config/Seeds/NumbersSeed.php b/tests/test_app/config/Seeds/NumbersSeed.php
index 5fb9d2297..faf17b58c 100644
--- a/tests/test_app/config/Seeds/NumbersSeed.php
+++ b/tests/test_app/config/Seeds/NumbersSeed.php
@@ -9,11 +9,6 @@ class NumbersSeed extends AbstractSeed
 {
     /**
      * Run Method.
-     *
-     * Write your database seeder using this method.
-     *
-     * More information on writing seeders is available here:
-     * https://book.cakephp.org/phinx/0/en/seeding.html
      */
     public function run(): void
     {
diff --git a/tests/test_app/config/Seeds/StoresSeed.php b/tests/test_app/config/Seeds/StoresSeed.php
new file mode 100644
index 000000000..7be59b1b7
--- /dev/null
+++ b/tests/test_app/config/Seeds/StoresSeed.php
@@ -0,0 +1,33 @@
+<?php
+
+use Cake\I18n\Date;
+use Cake\I18n\DateTime;
+use Migrations\AbstractSeed;
+
+/**
+ * NumbersSeed seed.
+ */
+class StoresSeed extends AbstractSeed
+{
+    /**
+     * Run Method.
+     */
+    public function run(): void
+    {
+        $data = [
+            [
+                'name' => 'foo',
+                'created' => new Date(),
+                'modified' => new Date(),
+            ],
+            [
+                'name' => 'foo_with_date',
+                'created' => new DateTime(),
+                'modified' => new DateTime(),
+            ],
+        ];
+
+        $table = $this->table('stores');
+        $table->insert($data)->save();
+    }
+}


Reviews:
user: login: markstory
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T13:59:26Z
user: login: dereuromark
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T15:40:43Z
user: login: markstory
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T21:30:15Z

Review comments:
diff_hunk: @@ -133,6 +136,88 @@ public function testExecute()
         $migrations->rollback(['target' => 'all']);
     }
 
+    /**
+     * Test executing the "seed" command with bulkinsert and timestamps
+     *
+     * @return void
+     */
+    public function testExecuteTimestampFields()
path: tests/TestCase/Command/Phinx/SeedTest.php
user: login: markstory
body: Did you mean to add this test to the phinx based seed command? Adding tests to `tests/TestCase/Command/SeedCommandTest.php` would be preferable.
created_at: 2025-04-09T13:59:15Z
author_association: MEMBER
diff_hunk: @@ -133,6 +136,88 @@ public function testExecute()
         $migrations->rollback(['target' => 'all']);
     }
 
+    /**
+     * Test executing the "seed" command with bulkinsert and timestamps
+     *
+     * @return void
+     */
+    public function testExecuteTimestampFields()
path: tests/TestCase/Command/Phinx/SeedTest.php
user: login: dereuromark
body: there were some tests already in here, but sure we could move them.
created_at: 2025-04-09T15:40:43Z
author_association: MEMBER
diff_hunk: @@ -133,6 +136,88 @@ public function testExecute()
         $migrations->rollback(['target' => 'all']);
     }
 
+    /**
+     * Test executing the "seed" command with bulkinsert and timestamps
+     *
+     * @return void
+     */
+    public function testExecuteTimestampFields()
path: tests/TestCase/Command/Phinx/SeedTest.php
user: login: markstory
body: The adapter you've changed isn't used when migrations uses the `phinx` backend which these command tests use.
created_at: 2025-04-09T21:30:15Z
author_association: MEMBER

PR comments:
user: login: markstory
created_at: 2025-04-08T01:57:08Z
body: > How could a test case look like for this?

There are a few tests for bulkinsert already, there could be a new test for bulkinsert with datetime values. Most of the tests we have for bulkinsert are in each driver implementation though.

> And in what class, for what method?

You could include a seed integration test. There are a few integration tests for `bin/cake migrations seed` and we could have another one that inserts into a table with datetime columns.
author_association: MEMBER
user: login: dereuromark
created_at: 2025-04-08T10:18:51Z
body: Hm its all super confusing as long as we didnt major and get rid of the overhead of two systems under the hood.
Current seeds in testapp still

    extends AbstractSeed

So I assume this is part why for tests they dont run into the code above.
Also, once I add a local DateTime::setToStringFormat() it also seems to cause issues.

I might need some help to get this covered.

In real prod env the above fix works fine (using BaseSeed ext) and saves DateTime properly now.
author_association: MEMBER
user: login: dereuromark
created_at: 2025-04-08T14:45:37Z
body: I removed the changes again, the tests still pass.
But locally, on my system, it does not pass for actual DB columns.
author_association: MEMBER
user: login: dereuromark
created_at: 2025-04-08T21:56:37Z
body: Hah now I can reproduce also in CI :)
But it seems for test run it does not make a difference, it skips this adapter and seems to use its own one.
author_association: MEMBER
user: login: dereuromark
created_at: 2025-04-11T16:34:11Z
body: @markstory Thanks a lot for the help
It indeed only gets triggered throw this part of the system

I was finally able to get it reproduced for Mysql both locally and on CI, and was able to show now that the fix indeed is helping.
author_association: MEMBER
user: login: dereuromark
created_at: 2025-04-11T16:39:59Z
body: The remaining error seems to be dependant on a fix in Phinx directly maybe?
https://github.com/cakephp/phinx/pull/2348

The rest seems now green instead of red :)
author_association: MEMBER
user: login: dereuromark
created_at: 2025-04-16T00:36:34Z
body: All fixed, and the phinx one should be merged soon.

PS: Lets please squash merge this one.
author_association: MEMBER
user: login: dereuromark
created_at: 2025-04-17T17:52:51Z
body: https://github.com/cakephp/phinx/releases/tag/0.16.7
has been released

I squashed

author_association: MEMBER
user: login: dereuromark
created_at: 2025-04-17T17:57:32Z
body: @markstory 
The remaining fails:
https://github.com/cakephp/migrations/actions/runs/14521746190/job/40744069928?pr=838
Are those on phinx or us?
author_association: MEMBER
user: login: markstory
created_at: 2025-04-18T02:45:07Z
body: > 4) Migrations\Test\TestCase\MigrationsTest::testSeed with data set #0 ('builtin')
> Cake\Database\Exception\QueryException: SQLSTATE[22007]: [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]The conversion of a nvarchar data type to a datetime data type resulted in an out-of-range value.
Query: INSERT INTO [dbo].[stores] ([name], [created], [modified]) VALUES ('foo', '4/17/25', '4/17/25'),('foo_with_date', '4/17/25, 5:55 PM', '4/17/25, 5:55 PM')

It looks like there are still some issues with datetimes being cast to the wrong string format. This could be a migrations issue. There was a specialized `bulkinsert` implementation added in #826
author_association: MEMBER
user: login: dereuromark
created_at: 2025-04-18T21:11:25Z
body: Thx, I think I finally got it.

The remaining fail is already on 4.x and due to the fact that we support still cake5.0, and we probably need a version switch on those tests.
author_association: MEMBER
user: login: markstory
created_at: 2025-04-19T16:38:12Z
body: > The remaining fail is already on 4.x and due to the fact that we support still cake5.0, and we probably need a version switch on those tests.

I have to bump the dev dependency to not use 5.x-dev.
author_association: MEMBER