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' + ] ]; } }