diff --git a/system/DataCaster/Cast/DatetimeCast.php b/system/DataCaster/Cast/DatetimeCast.php index b6fa97f9ddda..25f1e5fad448 100644 --- a/system/DataCaster/Cast/DatetimeCast.php +++ b/system/DataCaster/Cast/DatetimeCast.php @@ -43,12 +43,7 @@ public static function get( /** * @see https://www.php.net/manual/en/datetimeimmutable.createfromformat.php#datetimeimmutable.createfromformat.parameters */ - $format = match ($params[0] ?? '') { - '' => $helper->dateFormat['datetime'], - 'ms' => $helper->dateFormat['datetime-ms'], - 'us' => $helper->dateFormat['datetime-us'], - default => throw new InvalidArgumentException('Invalid parameter: ' . $params[0]), - }; + $format = self::getDateTimeFormat($params, $helper); return Time::createFromFormat($format, $value); } @@ -62,6 +57,29 @@ public static function set( self::invalidTypeValueError($value); } - return (string) $value; + if (! $helper instanceof BaseConnection) { + $message = 'The parameter $helper must be BaseConnection.'; + + throw new InvalidArgumentException($message); + } + + $format = self::getDateTimeFormat($params, $helper); + + return $value->format($format); + } + + /** + * Gets DateTime format from the DB connection. + * + * @param list $params Additional param + */ + protected static function getDateTimeFormat(array $params, BaseConnection $db): string + { + return match ($params[0] ?? '') { + '' => $db->dateFormat['datetime'], + 'ms' => $db->dateFormat['datetime-ms'], + 'us' => $db->dateFormat['datetime-us'], + default => throw new InvalidArgumentException('Invalid parameter: ' . $params[0]), + }; } } diff --git a/system/I18n/TimeTrait.php b/system/I18n/TimeTrait.php index fefb2fc544a3..3257ad06189f 100644 --- a/system/I18n/TimeTrait.php +++ b/system/I18n/TimeTrait.php @@ -81,10 +81,10 @@ public function __construct(?string $time = null, $timezone = null, ?string $loc if ($time === '' && static::$testNow instanceof self) { if ($timezone !== null) { $testNow = static::$testNow->setTimezone($timezone); - $time = $testNow->format('Y-m-d H:i:s'); + $time = $testNow->format('Y-m-d H:i:s.u'); } else { $timezone = static::$testNow->getTimezone(); - $time = static::$testNow->format('Y-m-d H:i:s'); + $time = static::$testNow->format('Y-m-d H:i:s.u'); } } @@ -97,7 +97,7 @@ public function __construct(?string $time = null, $timezone = null, ?string $loc if ($time !== '' && static::hasRelativeKeywords($time)) { $instance = new DateTime('now', $this->timezone); $instance->modify($time); - $time = $instance->format('Y-m-d H:i:s'); + $time = $instance->format('Y-m-d H:i:s.u'); } parent::__construct($time, $this->timezone); @@ -253,7 +253,7 @@ public static function createFromFormat($format, $datetime, $timezone = null) throw I18nException::forInvalidFormat($format); } - return new self($date->format('Y-m-d H:i:s'), $timezone); + return new self($date->format('Y-m-d H:i:s.u'), $timezone); } /** @@ -283,7 +283,7 @@ public static function createFromTimestamp(int $timestamp, $timezone = null, ?st */ public static function createFromInstance(DateTimeInterface $dateTime, ?string $locale = null) { - $date = $dateTime->format('Y-m-d H:i:s'); + $date = $dateTime->format('Y-m-d H:i:s.u'); $timezone = $dateTime->getTimezone(); return new self($date, $timezone, $locale); @@ -314,10 +314,11 @@ public static function instance(DateTime $dateTime, ?string $locale = null) */ public function toDateTime() { - $dateTime = new DateTime('', $this->getTimezone()); - $dateTime->setTimestamp(parent::getTimestamp()); - - return $dateTime; + return DateTime::createFromFormat( + 'Y-m-d H:i:s.u', + $this->format('Y-m-d H:i:s.u'), + $this->getTimezone() + ); } // -------------------------------------------------------------------- @@ -348,7 +349,7 @@ public static function setTestNow($datetime = null, $timezone = null, ?string $l if (is_string($datetime)) { $datetime = new self($datetime, $timezone, $locale); } elseif ($datetime instanceof DateTimeInterface && ! $datetime instanceof self) { - $datetime = new self($datetime->format('Y-m-d H:i:s'), $timezone); + $datetime = new self($datetime->format('Y-m-d H:i:s.u'), $timezone); } static::$testNow = $datetime; @@ -941,9 +942,9 @@ public function equals($testTime, ?string $timezone = null): bool $ourTime = $this->toDateTime() ->setTimezone(new DateTimeZone('UTC')) - ->format('Y-m-d H:i:s'); + ->format('Y-m-d H:i:s.u'); - return $testTime->format('Y-m-d H:i:s') === $ourTime; + return $testTime->format('Y-m-d H:i:s.u') === $ourTime; } /** @@ -956,15 +957,15 @@ public function equals($testTime, ?string $timezone = null): bool public function sameAs($testTime, ?string $timezone = null): bool { if ($testTime instanceof DateTimeInterface) { - $testTime = $testTime->format('Y-m-d H:i:s'); + $testTime = $testTime->format('Y-m-d H:i:s.u O'); } elseif (is_string($testTime)) { $timezone = $timezone ?: $this->timezone; $timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone); $testTime = new DateTime($testTime, $timezone); - $testTime = $testTime->format('Y-m-d H:i:s'); + $testTime = $testTime->format('Y-m-d H:i:s.u O'); } - $ourTime = $this->toDateTimeString(); + $ourTime = $this->format('Y-m-d H:i:s.u O'); return $testTime === $ourTime; } @@ -979,10 +980,16 @@ public function sameAs($testTime, ?string $timezone = null): bool */ public function isBefore($testTime, ?string $timezone = null): bool { - $testTime = $this->getUTCObject($testTime, $timezone)->getTimestamp(); - $ourTime = $this->getTimestamp(); + $testTime = $this->getUTCObject($testTime, $timezone); + + $testTimestamp = $testTime->getTimestamp(); + $ourTimestamp = $this->getTimestamp(); + + if ($ourTimestamp === $testTimestamp) { + return $this->format('u') < $testTime->format('u'); + } - return $ourTime < $testTime; + return $ourTimestamp < $testTimestamp; } /** @@ -995,10 +1002,16 @@ public function isBefore($testTime, ?string $timezone = null): bool */ public function isAfter($testTime, ?string $timezone = null): bool { - $testTime = $this->getUTCObject($testTime, $timezone)->getTimestamp(); - $ourTime = $this->getTimestamp(); + $testTime = $this->getUTCObject($testTime, $timezone); + + $testTimestamp = $testTime->getTimestamp(); + $ourTimestamp = $this->getTimestamp(); + + if ($ourTimestamp === $testTimestamp) { + return $this->format('u') > $testTime->format('u'); + } - return $ourTime > $testTime; + return $ourTimestamp > $testTimestamp; } // -------------------------------------------------------------------- diff --git a/tests/system/DataConverter/DataConverterTest.php b/tests/system/DataConverter/DataConverterTest.php index 6592f4d801c6..53a6361bb0de 100644 --- a/tests/system/DataConverter/DataConverterTest.php +++ b/tests/system/DataConverter/DataConverterTest.php @@ -368,7 +368,7 @@ public function testDateTimeConvertDataToDB(): void 'id' => 'int', 'date' => 'datetime', ]; - $converter = $this->createDataConverter($types); + $converter = $this->createDataConverter($types, [], db_connect()); $phpData = [ 'id' => '1', @@ -379,6 +379,23 @@ public function testDateTimeConvertDataToDB(): void $this->assertSame('2023-11-18 14:18:18', $data['date']); } + public function testDateTimeConvertDataToDBWithFormat(): void + { + $types = [ + 'id' => 'int', + 'date' => 'datetime[us]', + ]; + $converter = $this->createDataConverter($types, [], db_connect()); + + $phpData = [ + 'id' => '1', + 'date' => Time::parse('2009-02-15 00:00:01.123456'), + ]; + $data = $converter->toDataSource($phpData); + + $this->assertSame('2009-02-15 00:00:01.123456', $data['date']); + } + public function testTimestampConvertDataFromDB(): void { $types = [ @@ -603,7 +620,7 @@ public function testExtract(): void 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; - $converter = $this->createDataConverter($types); + $converter = $this->createDataConverter($types, [], db_connect()); $phpData = [ 'id' => 1, @@ -635,7 +652,7 @@ public function testExtractWithExtractMethod(): void 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; - $converter = $this->createDataConverter($types, [], null, 'toRawArray'); + $converter = $this->createDataConverter($types, [], db_connect(), 'toRawArray'); $phpData = [ 'id' => 1, diff --git a/tests/system/I18n/TimeTest.php b/tests/system/I18n/TimeTest.php index 34516948ef65..0e5bc733407c 100644 --- a/tests/system/I18n/TimeTest.php +++ b/tests/system/I18n/TimeTest.php @@ -61,7 +61,7 @@ public function testNewTimeNow(): void 'en_US', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, - 'America/Chicago', // Default for CodeIgniter + 'America/Chicago', IntlDateFormatter::GREGORIAN, 'yyyy-MM-dd HH:mm:ss' ); @@ -76,7 +76,7 @@ public function testTimeWithTimezone(): void 'en_US', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, - 'Europe/London', // Default for CodeIgniter + 'Europe/London', IntlDateFormatter::GREGORIAN, 'yyyy-MM-dd HH:mm:ss' ); @@ -92,7 +92,7 @@ public function testTimeWithTimezoneAndLocale(): void 'fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, - 'Europe/London', // Default for CodeIgniter + 'Europe/London', IntlDateFormatter::GREGORIAN, 'yyyy-MM-dd HH:mm:ss' ); @@ -234,6 +234,13 @@ public function testCreateFromFormat(): void $this->assertCloseEnoughString(date('2017-01-15 H:i:s', $now->getTimestamp()), $time->toDateTimeString()); } + public function testCreateFromFormatWithMicroseconds(): void + { + $time = Time::createFromFormat('Y-m-d H:i:s.u', '2024-07-09 09:13:34.654321'); + + $this->assertSame('2024-07-09 09:13:34.654321', $time->format('Y-m-d H:i:s.u')); + } + public function testCreateFromFormatWithTimezoneString(): void { $time = Time::createFromFormat('F j, Y', 'January 15, 2017', 'Europe/London'); @@ -875,13 +882,21 @@ public function testEqualWithString(): void $this->assertTrue($time1->equals('January 11, 2017 03:50:00', 'Europe/London')); } - public function testEqualWithStringAndNotimezone(): void + public function testEqualWithStringAndNoTimezone(): void { $time1 = Time::parse('January 10, 2017 21:50:00', 'America/Chicago'); $this->assertTrue($time1->equals('January 10, 2017 21:50:00')); } + public function testEqualWithDifferentMicroseconds(): void + { + $time1 = new Time('2024-01-01 12:00:00.654321'); + $time2 = new Time('2024-01-01 12:00:00'); + + $this->assertFalse($time1->equals($time2)); + } + public function testSameSuccess(): void { $time1 = Time::parse('January 10, 2017 21:50:00', 'America/Chicago'); @@ -921,6 +936,24 @@ public function testBefore(): void $this->assertFalse($time2->isBefore($time1)); } + public function testBeforeSameTime(): void + { + $time1 = new Time('2024-01-01 12:00:00.000000'); + $time2 = new Time('2024-01-01 12:00:00.000000'); + + $this->assertFalse($time1->isBefore($time2)); + $this->assertFalse($time2->isBefore($time1)); + } + + public function testBeforeWithMicroseconds(): void + { + $time1 = new Time('2024-01-01 12:00:00.000000'); + $time2 = new Time('2024-01-01 12:00:00.654321'); + + $this->assertTrue($time1->isBefore($time2)); + $this->assertFalse($time2->isBefore($time1)); + } + public function testAfter(): void { $time1 = Time::parse('January 10, 2017 21:50:00', 'America/Chicago'); @@ -930,6 +963,24 @@ public function testAfter(): void $this->assertTrue($time2->isAfter($time1)); } + public function testAfterSameTime(): void + { + $time1 = new Time('2024-01-01 12:00:00.000000'); + $time2 = new Time('2024-01-01 12:00:00.000000'); + + $this->assertFalse($time1->isAfter($time2)); + $this->assertFalse($time2->isAfter($time1)); + } + + public function testAfterWithMicroseconds(): void + { + $time1 = new Time('2024-01-01 12:00:00.654321'); + $time2 = new Time('2024-01-01 12:00:00.000000'); + + $this->assertTrue($time1->isAfter($time2)); + $this->assertFalse($time2->isAfter($time1)); + } + public function testHumanizeYearsSingle(): void { Time::setTestNow('March 10, 2017', 'America/Chicago'); diff --git a/user_guide_src/source/changelogs/v4.6.0.rst b/user_guide_src/source/changelogs/v4.6.0.rst index 8872615a3fc6..e001f5c9d4c2 100644 --- a/user_guide_src/source/changelogs/v4.6.0.rst +++ b/user_guide_src/source/changelogs/v4.6.0.rst @@ -59,6 +59,12 @@ executed twice, an exception will be thrown. See .. _v460-interface-changes: +Time with Microseconds +---------------------- + +Fixed bugs that some methods in ``Time`` to lose microseconds have been fixed. +See :ref:`Upgrading Guide ` for details. + Interface Changes ================= diff --git a/user_guide_src/source/installation/upgrade_460.rst b/user_guide_src/source/installation/upgrade_460.rst index 5eccc23abd05..7078110b7068 100644 --- a/user_guide_src/source/installation/upgrade_460.rst +++ b/user_guide_src/source/installation/upgrade_460.rst @@ -29,6 +29,42 @@ See :ref:`ChangeLog ` for details. If you have code that catches these exceptions, change the exception classes. +.. _upgrade-460-time-keeps-microseconds: + +Time keeps Microseconds +======================= + +In previous versions, :doc:`Time <../libraries/time>` lost microseconds in some +cases. But the bugs have been fixed. + +The results of the ``Time`` comparison may differ due to these fixes: + +.. literalinclude:: upgrade_460/006.php + :lines: 2- + +In a such case, you need to remove the microseconds: + +.. literalinclude:: upgrade_460/007.php + :lines: 2- + +The following cases now keeps microseconds: + +.. literalinclude:: upgrade_460/002.php + :lines: 2- + +.. literalinclude:: upgrade_460/003.php + :lines: 2- + +Note that ``Time`` with the current time has been holding microseconds since before. + +.. literalinclude:: upgrade_460/004.php + :lines: 2- + +Also, methods that returns an ``int`` still lose the microseconds. + +.. literalinclude:: upgrade_460/005.php + :lines: 2- + .. _upgrade-460-registrars-with-dirty-hack: Registrars with Dirty Hack diff --git a/user_guide_src/source/installation/upgrade_460/002.php b/user_guide_src/source/installation/upgrade_460/002.php new file mode 100644 index 000000000000..91747d7b5464 --- /dev/null +++ b/user_guide_src/source/installation/upgrade_460/002.php @@ -0,0 +1,8 @@ +format('Y-m-d H:i:s.u'); +// Before: 2024-07-09 09:13:34.000000 +// After: 2024-07-09 09:13:34.654321 diff --git a/user_guide_src/source/installation/upgrade_460/003.php b/user_guide_src/source/installation/upgrade_460/003.php new file mode 100644 index 000000000000..65914ece9c7c --- /dev/null +++ b/user_guide_src/source/installation/upgrade_460/003.php @@ -0,0 +1,8 @@ +format('Y-m-d H:i:s.u'); +// Before: 2024-07-26 21:05:57.000000 +// After: 2024-07-26 21:05:57.857235 diff --git a/user_guide_src/source/installation/upgrade_460/004.php b/user_guide_src/source/installation/upgrade_460/004.php new file mode 100644 index 000000000000..dab6489fd68b --- /dev/null +++ b/user_guide_src/source/installation/upgrade_460/004.php @@ -0,0 +1,8 @@ +format('Y-m-d H:i:s.u'); +// Before: 2024-07-26 21:39:32.249072 +// After: 2024-07-26 21:39:32.249072 diff --git a/user_guide_src/source/installation/upgrade_460/005.php b/user_guide_src/source/installation/upgrade_460/005.php new file mode 100644 index 000000000000..a5e80c09b778 --- /dev/null +++ b/user_guide_src/source/installation/upgrade_460/005.php @@ -0,0 +1,9 @@ +getTimestamp(); // 1704110400 + +$time2 = new Time('2024-01-01 12:00:00.654321'); +echo $time2->getTimestamp(); // 1704110400 diff --git a/user_guide_src/source/installation/upgrade_460/006.php b/user_guide_src/source/installation/upgrade_460/006.php new file mode 100644 index 000000000000..316d802ada48 --- /dev/null +++ b/user_guide_src/source/installation/upgrade_460/006.php @@ -0,0 +1,10 @@ +equals($time2); +// Before: true +// After: false diff --git a/user_guide_src/source/installation/upgrade_460/007.php b/user_guide_src/source/installation/upgrade_460/007.php new file mode 100644 index 000000000000..22cadc8c0773 --- /dev/null +++ b/user_guide_src/source/installation/upgrade_460/007.php @@ -0,0 +1,17 @@ +format('Y-m-d H:i:s'), + $time1->getTimezone() +); + +$time1->equals($time2); +// Before: true +// After: true diff --git a/user_guide_src/source/models/model.rst b/user_guide_src/source/models/model.rst index c77e68cb81b8..5d9bb6ebdb4e 100644 --- a/user_guide_src/source/models/model.rst +++ b/user_guide_src/source/models/model.rst @@ -395,6 +395,18 @@ The datetime format is set in the ``dateFormat`` array of the :ref:`database configuration ` in the **app/Config/Database.php** file. +.. note:: + When you set ``ms`` or ``us`` as a parameter, **Model** takes care of second's + fractional part of the Time. But **Query Builder** does not. So you still need + to use the ``format()`` method when you pass the Time to Query Builder's methods + like ``where()``: + + .. literalinclude:: model/063.php + :lines: 2- + +.. note:: Prior to v4.6.0, you cannot use ``ms`` or ``us`` as a parameter. + Because the second's fractional part of Time was lost due to bugs. + Custom Casting ============== diff --git a/user_guide_src/source/models/model/063.php b/user_guide_src/source/models/model/063.php new file mode 100644 index 000000000000..cc75d0648421 --- /dev/null +++ b/user_guide_src/source/models/model/063.php @@ -0,0 +1,13 @@ +where('my_dt_field', $now->format('Y-m-d H:i:s.u'))->findAll(); +// Generates: SELECT * FROM `my_table` WHERE `my_dt_field` = '2024-07-28 18:57:58.900326' + +// But the following code loses the microseconds. +$model->where('my_dt_field', $now)->findAll(); +// Generates: SELECT * FROM `my_table` WHERE `my_dt_field` = '2024-07-28 18:57:58'