diff --git a/README.md b/README.md
index 8267f58..2f1791f 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@
## Overview
Value Object representing time in an immutable and strict way, focused on safe parsing, formatting and normalization.
+
## Installation
@@ -35,6 +36,20 @@ normalized to UTC internally.
An `Instant` represents a single point on the timeline, always stored in UTC with microsecond precision.
+#### Creating from the current moment
+
+Captures the current moment with microsecond precision, normalized to UTC.
+
+```php
+use TinyBlocks\Time\Instant;
+
+$instant = Instant::now();
+
+$instant->toIso8601(); # 2026-02-17T10:30:00+00:00 (current UTC time)
+$instant->toUnixSeconds(); # 1771324200 (current Unix timestamp)
+$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC, with microseconds)
+```
+
#### Creating from a string
Parses a date-time string with an explicit UTC offset. The value is normalized to UTC regardless of the original offset.
@@ -49,31 +64,41 @@ $instant->toUnixSeconds(); # 1771345800
$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC)
```
-#### Creating from Unix seconds
+#### Creating from a database timestamp
-Creates an `Instant` from a Unix timestamp in seconds.
+Parses a database date-time string as UTC, with or without microsecond precision (e.g. MySQL `DATETIME`
+or `DATETIME(6)`).
```php
use TinyBlocks\Time\Instant;
-$instant = Instant::fromUnixSeconds(seconds: 0);
+$instant = Instant::fromString(value: '2026-02-17 08:27:21.106011');
-$instant->toIso8601(); # 1970-01-01T00:00:00+00:00
-$instant->toUnixSeconds(); # 0
+$instant->toIso8601(); # 2026-02-17T08:27:21+00:00
+$instant->toDateTimeImmutable()->format('Y-m-d H:i:s.u'); # 2026-02-17 08:27:21.106011
```
-#### Creating from the current moment
+Also supports timestamps without fractional seconds:
-Captures the current moment with microsecond precision, normalized to UTC.
+```php
+use TinyBlocks\Time\Instant;
+
+$instant = Instant::fromString(value: '2026-02-17 08:27:21');
+
+$instant->toIso8601(); # 2026-02-17T08:27:21+00:00
+```
+
+#### Creating from Unix seconds
+
+Creates an `Instant` from a Unix timestamp in seconds.
```php
use TinyBlocks\Time\Instant;
-$instant = Instant::now();
+$instant = Instant::fromUnixSeconds(seconds: 0);
-$instant->toIso8601(); # 2026-02-17T10:30:00+00:00 (current UTC time)
-$instant->toUnixSeconds(); # 1771324200 (current Unix timestamp)
-$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC, with microseconds)
+$instant->toIso8601(); # 1970-01-01T00:00:00+00:00
+$instant->toUnixSeconds(); # 0
```
#### Formatting as ISO 8601
@@ -98,7 +123,7 @@ use TinyBlocks\Time\Instant;
$instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00');
$dateTime = $instant->toDateTimeImmutable();
-$dateTime->getTimezone()->getName(); # UTC
+$dateTime->getTimezone()->getName(); # UTC
$dateTime->format('Y-m-d\TH:i:s.u'); # 2026-02-17T10:30:00.000000
```
@@ -198,9 +223,9 @@ use TinyBlocks\Time\Timezones;
$timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Asia/Tokyo');
-$timezones->findByIdentifierOrUtc(iana: 'Asia/Tokyo'); # Timezone("Asia/Tokyo")
-$timezones->findByIdentifierOrUtc(iana: 'Europe/London'); # Timezone("UTC")
-```
+$timezones->findByIdentifierOrUtc(iana: 'Asia/Tokyo'); # Timezone("Asia/Tokyo")
+$timezones->findByIdentifierOrUtc(iana: 'Europe/London'); # Timezone("UTC")
+```
#### Checking if a timezone exists in the collection
@@ -213,6 +238,18 @@ $timezones->contains(iana: 'Asia/Tokyo'); # true
$timezones->contains(iana: 'America/New_York'); # false
```
+#### Getting all identifiers as strings
+
+Returns all timezone identifiers as plain strings:
+
+```php
+use TinyBlocks\Time\Timezones;
+
+$timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Europe/London');
+
+$timezones->toStrings(); # ["UTC", "America/Sao_Paulo", "Europe/London"]
+```
+
## License
diff --git a/src/Internal/Decoders/DatabaseDateTimeDecoder.php b/src/Internal/Decoders/DatabaseDateTimeDecoder.php
new file mode 100644
index 0000000..adb82f3
--- /dev/null
+++ b/src/Internal/Decoders/DatabaseDateTimeDecoder.php
@@ -0,0 +1,28 @@
+toDateTimeZone();
+ $format = $hasMicroseconds ? self::FORMAT_MICRO : self::FORMAT;
+ $parsed = DateTimeImmutable::createFromFormat($format, $value, $utc);
+
+ if ($parsed === false || DateTimeImmutable::getLastErrors() !== false) {
+ return null;
+ }
+
+ return $parsed->setTimezone($utc);
+ }
+}
diff --git a/src/Internal/TextDecoder.php b/src/Internal/TextDecoder.php
index b6ffc85..c589ee8 100644
--- a/src/Internal/TextDecoder.php
+++ b/src/Internal/TextDecoder.php
@@ -5,26 +5,26 @@
namespace TinyBlocks\Time\Internal;
use DateTimeImmutable;
+use TinyBlocks\Time\Internal\Decoders\DatabaseDateTimeDecoder;
use TinyBlocks\Time\Internal\Decoders\Decoder;
use TinyBlocks\Time\Internal\Decoders\OffsetDateTimeDecoder;
use TinyBlocks\Time\Internal\Exceptions\InvalidInstant;
final readonly class TextDecoder
{
- /** @var Decoder[] */
- private array $decoders;
-
/**
- * @param Decoder[] $decoders
+ * @param list $decoders
*/
- private function __construct(array $decoders)
+ private function __construct(private array $decoders)
{
- $this->decoders = $decoders;
}
- public static function create(): self
+ public static function create(): TextDecoder
{
- return new TextDecoder(decoders: [new OffsetDateTimeDecoder()]);
+ return new TextDecoder(decoders: [
+ new OffsetDateTimeDecoder(),
+ new DatabaseDateTimeDecoder()
+ ]);
}
public function decode(string $value): DateTimeImmutable
diff --git a/tests/InstantTest.php b/tests/InstantTest.php
index 186902e..5f5b81a 100644
--- a/tests/InstantTest.php
+++ b/tests/InstantTest.php
@@ -272,6 +272,55 @@ public function testInstantWhenInvalidString(string $value): void
Instant::fromString(value: $value);
}
+ #[DataProvider('validDatabaseStringsDataProvider')]
+ public function testInstantFromDatabaseString(
+ string $value,
+ string $expectedIso8601
+ ): void {
+ /** @Given a valid database date-time string in UTC */
+ /** @When creating an Instant from the string */
+ $instant = Instant::fromString(value: $value);
+
+ /** @Then the ISO 8601 representation should match the expected UTC value */
+ self::assertSame($expectedIso8601, $instant->toIso8601());
+ }
+
+ public function testInstantFromDatabaseStringPreservesMicroseconds(): void
+ {
+ /** @Given a database date-time string with microsecond precision */
+ $instant = Instant::fromString(value: '2026-02-17 08:27:21.106011');
+
+ /** @When accessing the underlying DateTimeImmutable */
+ $dateTime = $instant->toDateTimeImmutable();
+
+ /** @Then the microseconds should be preserved */
+ self::assertSame('106011', $dateTime->format('u'));
+ }
+
+ public function testInstantFromDatabaseStringWithoutMicrosecondsHasZeroMicroseconds(): void
+ {
+ /** @Given a database date-time string without microseconds */
+ $instant = Instant::fromString(value: '2026-02-17 08:27:21');
+
+ /** @When accessing the underlying DateTimeImmutable */
+ $dateTime = $instant->toDateTimeImmutable();
+
+ /** @Then the microseconds should be zero */
+ self::assertSame('000000', $dateTime->format('u'));
+ }
+
+ public function testInstantFromDatabaseStringIsInUtc(): void
+ {
+ /** @Given a database date-time string */
+ $instant = Instant::fromString(value: '2026-02-17 08:27:21.106011');
+
+ /** @When converting to DateTimeImmutable */
+ $dateTime = $instant->toDateTimeImmutable();
+
+ /** @Then the timezone should be UTC */
+ self::assertSame('UTC', $dateTime->getTimezone()->getName());
+ }
+
public static function validStringsDataProvider(): array
{
return [
@@ -351,19 +400,56 @@ public static function unixSecondsDataProvider(): array
public static function invalidStringsDataProvider(): array
{
return [
- 'Date only' => ['value' => '2026-02-17'],
- 'Time only' => ['value' => '10:30:00'],
- 'Plain text' => ['value' => 'not-a-date'],
- 'Invalid day' => ['value' => '2026-02-30T10:30:00+00:00'],
- 'Empty string' => ['value' => ''],
- 'Invalid month' => ['value' => '2026-13-17T10:30:00+00:00'],
- 'Missing offset' => ['value' => '2026-02-17T10:30:00'],
- 'Truncated offset' => ['value' => '2026-02-17T10:30:00+00'],
- 'Slash-separated date' => ['value' => '2026/02/17T10:30:00+00:00'],
- 'Missing time separator' => ['value' => '2026-02-17 10:30:00+00:00'],
- 'Z suffix instead offset' => ['value' => '2026-02-17T10:30:00Z'],
- 'With fractional seconds' => ['value' => '2026-02-17T10:30:00.123456+00:00'],
- 'Unix timestamp as string' => ['value' => '1771324200']
+ 'Date only' => ['value' => '2026-02-17'],
+ 'Time only' => ['value' => '10:30:00'],
+ 'Plain text' => ['value' => 'not-a-date'],
+ 'Invalid day' => ['value' => '2026-02-30T10:30:00+00:00'],
+ 'Empty string' => ['value' => ''],
+ 'Invalid month' => ['value' => '2026-13-17T10:30:00+00:00'],
+ 'Missing offset' => ['value' => '2026-02-17T10:30:00'],
+ 'Truncated offset' => ['value' => '2026-02-17T10:30:00+00'],
+ 'Slash-separated date' => ['value' => '2026/02/17T10:30:00+00:00'],
+ 'Missing time separator' => ['value' => '2026-02-17 10:30:00+00:00'],
+ 'Z suffix instead offset' => ['value' => '2026-02-17T10:30:00Z'],
+ 'With fractional seconds' => ['value' => '2026-02-17T10:30:00.123456+00:00'],
+ 'Unix timestamp as string' => ['value' => '1771324200'],
+ 'Database format with invalid day' => ['value' => '2026-02-30 08:27:21.106011'],
+ 'Database format with T separator' => ['value' => '2026-02-17T08:27:21.106011'],
+ 'Database format with invalid month' => ['value' => '2026-13-17 08:27:21.106011']
+ ];
+ }
+
+ public static function validDatabaseStringsDataProvider(): array
+ {
+ return [
+ 'End of day' => [
+ 'value' => '2026-12-31 23:59:59.999999',
+ 'expectedIso8601' => '2026-12-31T23:59:59+00:00'
+ ],
+ 'Full microseconds' => [
+ 'value' => '2026-02-17 08:27:21.106011',
+ 'expectedIso8601' => '2026-02-17T08:27:21+00:00'
+ ],
+ 'Midnight with zeros' => [
+ 'value' => '2026-01-01 00:00:00.000000',
+ 'expectedIso8601' => '2026-01-01T00:00:00+00:00'
+ ],
+ 'Without microseconds' => [
+ 'value' => '2026-02-17 08:27:21',
+ 'expectedIso8601' => '2026-02-17T08:27:21+00:00'
+ ],
+ 'Three digit fraction' => [
+ 'value' => '2026-02-17 08:27:21.106',
+ 'expectedIso8601' => '2026-02-17T08:27:21+00:00'
+ ],
+ 'Single digit fraction' => [
+ 'value' => '2026-02-17 08:27:21.1',
+ 'expectedIso8601' => '2026-02-17T08:27:21+00:00'
+ ],
+ 'Midnight without microseconds' => [
+ 'value' => '2026-01-01 00:00:00',
+ 'expectedIso8601' => '2026-01-01T00:00:00+00:00'
+ ]
];
}
}