Skip to content

Commit 745c3d7

Browse files
authored
Release/1.2.0 (#3)
1 parent a450fb0 commit 745c3d7

4 files changed

Lines changed: 187 additions & 36 deletions

File tree

README.md

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
## Overview
1717

1818
Value Object representing time in an immutable and strict way, focused on safe parsing, formatting and normalization.
19+
1920
<div id='installation'></div>
2021

2122
## Installation
@@ -35,6 +36,20 @@ normalized to UTC internally.
3536

3637
An `Instant` represents a single point on the timeline, always stored in UTC with microsecond precision.
3738

39+
#### Creating from the current moment
40+
41+
Captures the current moment with microsecond precision, normalized to UTC.
42+
43+
```php
44+
use TinyBlocks\Time\Instant;
45+
46+
$instant = Instant::now();
47+
48+
$instant->toIso8601(); # 2026-02-17T10:30:00+00:00 (current UTC time)
49+
$instant->toUnixSeconds(); # 1771324200 (current Unix timestamp)
50+
$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC, with microseconds)
51+
```
52+
3853
#### Creating from a string
3954

4055
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
4964
$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC)
5065
```
5166

52-
#### Creating from Unix seconds
67+
#### Creating from a database timestamp
5368

54-
Creates an `Instant` from a Unix timestamp in seconds.
69+
Parses a database date-time string as UTC, with or without microsecond precision (e.g. MySQL `DATETIME`
70+
or `DATETIME(6)`).
5571

5672
```php
5773
use TinyBlocks\Time\Instant;
5874

59-
$instant = Instant::fromUnixSeconds(seconds: 0);
75+
$instant = Instant::fromString(value: '2026-02-17 08:27:21.106011');
6076

61-
$instant->toIso8601(); # 1970-01-01T00:00:00+00:00
62-
$instant->toUnixSeconds(); # 0
77+
$instant->toIso8601(); # 2026-02-17T08:27:21+00:00
78+
$instant->toDateTimeImmutable()->format('Y-m-d H:i:s.u'); # 2026-02-17 08:27:21.106011
6379
```
6480

65-
#### Creating from the current moment
81+
Also supports timestamps without fractional seconds:
6682

67-
Captures the current moment with microsecond precision, normalized to UTC.
83+
```php
84+
use TinyBlocks\Time\Instant;
85+
86+
$instant = Instant::fromString(value: '2026-02-17 08:27:21');
87+
88+
$instant->toIso8601(); # 2026-02-17T08:27:21+00:00
89+
```
90+
91+
#### Creating from Unix seconds
92+
93+
Creates an `Instant` from a Unix timestamp in seconds.
6894

6995
```php
7096
use TinyBlocks\Time\Instant;
7197

72-
$instant = Instant::now();
98+
$instant = Instant::fromUnixSeconds(seconds: 0);
7399

74-
$instant->toIso8601(); # 2026-02-17T10:30:00+00:00 (current UTC time)
75-
$instant->toUnixSeconds(); # 1771324200 (current Unix timestamp)
76-
$instant->toDateTimeImmutable(); # DateTimeImmutable (UTC, with microseconds)
100+
$instant->toIso8601(); # 1970-01-01T00:00:00+00:00
101+
$instant->toUnixSeconds(); # 0
77102
```
78103

79104
#### Formatting as ISO 8601
@@ -98,7 +123,7 @@ use TinyBlocks\Time\Instant;
98123
$instant = Instant::fromString(value: '2026-02-17T10:30:00+00:00');
99124
$dateTime = $instant->toDateTimeImmutable();
100125

101-
$dateTime->getTimezone()->getName(); # UTC
126+
$dateTime->getTimezone()->getName(); # UTC
102127
$dateTime->format('Y-m-d\TH:i:s.u'); # 2026-02-17T10:30:00.000000
103128
```
104129

@@ -198,9 +223,9 @@ use TinyBlocks\Time\Timezones;
198223

199224
$timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Asia/Tokyo');
200225

201-
$timezones->findByIdentifierOrUtc(iana: 'Asia/Tokyo'); # Timezone("Asia/Tokyo")
202-
$timezones->findByIdentifierOrUtc(iana: 'Europe/London'); # Timezone("UTC")
203-
```
226+
$timezones->findByIdentifierOrUtc(iana: 'Asia/Tokyo'); # Timezone("Asia/Tokyo")
227+
$timezones->findByIdentifierOrUtc(iana: 'Europe/London'); # Timezone("UTC")
228+
```
204229

205230
#### Checking if a timezone exists in the collection
206231

@@ -213,6 +238,18 @@ $timezones->contains(iana: 'Asia/Tokyo'); # true
213238
$timezones->contains(iana: 'America/New_York'); # false
214239
```
215240

241+
#### Getting all identifiers as strings
242+
243+
Returns all timezone identifiers as plain strings:
244+
245+
```php
246+
use TinyBlocks\Time\Timezones;
247+
248+
$timezones = Timezones::fromStrings('UTC', 'America/Sao_Paulo', 'Europe/London');
249+
250+
$timezones->toStrings(); # ["UTC", "America/Sao_Paulo", "Europe/London"]
251+
```
252+
216253
<div id='license'></div>
217254

218255
## License
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\Time\Internal\Decoders;
6+
7+
use DateTimeImmutable;
8+
use TinyBlocks\Time\Timezone;
9+
10+
final readonly class DatabaseDateTimeDecoder implements Decoder
11+
{
12+
private const string FORMAT = 'Y-m-d H:i:s';
13+
private const string FORMAT_MICRO = 'Y-m-d H:i:s.u';
14+
15+
public function decode(string $value): ?DateTimeImmutable
16+
{
17+
$hasMicroseconds = str_contains($value, '.');
18+
$utc = Timezone::utc()->toDateTimeZone();
19+
$format = $hasMicroseconds ? self::FORMAT_MICRO : self::FORMAT;
20+
$parsed = DateTimeImmutable::createFromFormat($format, $value, $utc);
21+
22+
if ($parsed === false || DateTimeImmutable::getLastErrors() !== false) {
23+
return null;
24+
}
25+
26+
return $parsed->setTimezone($utc);
27+
}
28+
}

src/Internal/TextDecoder.php

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,26 @@
55
namespace TinyBlocks\Time\Internal;
66

77
use DateTimeImmutable;
8+
use TinyBlocks\Time\Internal\Decoders\DatabaseDateTimeDecoder;
89
use TinyBlocks\Time\Internal\Decoders\Decoder;
910
use TinyBlocks\Time\Internal\Decoders\OffsetDateTimeDecoder;
1011
use TinyBlocks\Time\Internal\Exceptions\InvalidInstant;
1112

1213
final readonly class TextDecoder
1314
{
14-
/** @var Decoder[] */
15-
private array $decoders;
16-
1715
/**
18-
* @param Decoder[] $decoders
16+
* @param list<Decoder> $decoders
1917
*/
20-
private function __construct(array $decoders)
18+
private function __construct(private array $decoders)
2119
{
22-
$this->decoders = $decoders;
2320
}
2421

25-
public static function create(): self
22+
public static function create(): TextDecoder
2623
{
27-
return new TextDecoder(decoders: [new OffsetDateTimeDecoder()]);
24+
return new TextDecoder(decoders: [
25+
new OffsetDateTimeDecoder(),
26+
new DatabaseDateTimeDecoder()
27+
]);
2828
}
2929

3030
public function decode(string $value): DateTimeImmutable

tests/InstantTest.php

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,55 @@ public function testInstantWhenInvalidString(string $value): void
272272
Instant::fromString(value: $value);
273273
}
274274

275+
#[DataProvider('validDatabaseStringsDataProvider')]
276+
public function testInstantFromDatabaseString(
277+
string $value,
278+
string $expectedIso8601
279+
): void {
280+
/** @Given a valid database date-time string in UTC */
281+
/** @When creating an Instant from the string */
282+
$instant = Instant::fromString(value: $value);
283+
284+
/** @Then the ISO 8601 representation should match the expected UTC value */
285+
self::assertSame($expectedIso8601, $instant->toIso8601());
286+
}
287+
288+
public function testInstantFromDatabaseStringPreservesMicroseconds(): void
289+
{
290+
/** @Given a database date-time string with microsecond precision */
291+
$instant = Instant::fromString(value: '2026-02-17 08:27:21.106011');
292+
293+
/** @When accessing the underlying DateTimeImmutable */
294+
$dateTime = $instant->toDateTimeImmutable();
295+
296+
/** @Then the microseconds should be preserved */
297+
self::assertSame('106011', $dateTime->format('u'));
298+
}
299+
300+
public function testInstantFromDatabaseStringWithoutMicrosecondsHasZeroMicroseconds(): void
301+
{
302+
/** @Given a database date-time string without microseconds */
303+
$instant = Instant::fromString(value: '2026-02-17 08:27:21');
304+
305+
/** @When accessing the underlying DateTimeImmutable */
306+
$dateTime = $instant->toDateTimeImmutable();
307+
308+
/** @Then the microseconds should be zero */
309+
self::assertSame('000000', $dateTime->format('u'));
310+
}
311+
312+
public function testInstantFromDatabaseStringIsInUtc(): void
313+
{
314+
/** @Given a database date-time string */
315+
$instant = Instant::fromString(value: '2026-02-17 08:27:21.106011');
316+
317+
/** @When converting to DateTimeImmutable */
318+
$dateTime = $instant->toDateTimeImmutable();
319+
320+
/** @Then the timezone should be UTC */
321+
self::assertSame('UTC', $dateTime->getTimezone()->getName());
322+
}
323+
275324
public static function validStringsDataProvider(): array
276325
{
277326
return [
@@ -351,19 +400,56 @@ public static function unixSecondsDataProvider(): array
351400
public static function invalidStringsDataProvider(): array
352401
{
353402
return [
354-
'Date only' => ['value' => '2026-02-17'],
355-
'Time only' => ['value' => '10:30:00'],
356-
'Plain text' => ['value' => 'not-a-date'],
357-
'Invalid day' => ['value' => '2026-02-30T10:30:00+00:00'],
358-
'Empty string' => ['value' => ''],
359-
'Invalid month' => ['value' => '2026-13-17T10:30:00+00:00'],
360-
'Missing offset' => ['value' => '2026-02-17T10:30:00'],
361-
'Truncated offset' => ['value' => '2026-02-17T10:30:00+00'],
362-
'Slash-separated date' => ['value' => '2026/02/17T10:30:00+00:00'],
363-
'Missing time separator' => ['value' => '2026-02-17 10:30:00+00:00'],
364-
'Z suffix instead offset' => ['value' => '2026-02-17T10:30:00Z'],
365-
'With fractional seconds' => ['value' => '2026-02-17T10:30:00.123456+00:00'],
366-
'Unix timestamp as string' => ['value' => '1771324200']
403+
'Date only' => ['value' => '2026-02-17'],
404+
'Time only' => ['value' => '10:30:00'],
405+
'Plain text' => ['value' => 'not-a-date'],
406+
'Invalid day' => ['value' => '2026-02-30T10:30:00+00:00'],
407+
'Empty string' => ['value' => ''],
408+
'Invalid month' => ['value' => '2026-13-17T10:30:00+00:00'],
409+
'Missing offset' => ['value' => '2026-02-17T10:30:00'],
410+
'Truncated offset' => ['value' => '2026-02-17T10:30:00+00'],
411+
'Slash-separated date' => ['value' => '2026/02/17T10:30:00+00:00'],
412+
'Missing time separator' => ['value' => '2026-02-17 10:30:00+00:00'],
413+
'Z suffix instead offset' => ['value' => '2026-02-17T10:30:00Z'],
414+
'With fractional seconds' => ['value' => '2026-02-17T10:30:00.123456+00:00'],
415+
'Unix timestamp as string' => ['value' => '1771324200'],
416+
'Database format with invalid day' => ['value' => '2026-02-30 08:27:21.106011'],
417+
'Database format with T separator' => ['value' => '2026-02-17T08:27:21.106011'],
418+
'Database format with invalid month' => ['value' => '2026-13-17 08:27:21.106011']
419+
];
420+
}
421+
422+
public static function validDatabaseStringsDataProvider(): array
423+
{
424+
return [
425+
'End of day' => [
426+
'value' => '2026-12-31 23:59:59.999999',
427+
'expectedIso8601' => '2026-12-31T23:59:59+00:00'
428+
],
429+
'Full microseconds' => [
430+
'value' => '2026-02-17 08:27:21.106011',
431+
'expectedIso8601' => '2026-02-17T08:27:21+00:00'
432+
],
433+
'Midnight with zeros' => [
434+
'value' => '2026-01-01 00:00:00.000000',
435+
'expectedIso8601' => '2026-01-01T00:00:00+00:00'
436+
],
437+
'Without microseconds' => [
438+
'value' => '2026-02-17 08:27:21',
439+
'expectedIso8601' => '2026-02-17T08:27:21+00:00'
440+
],
441+
'Three digit fraction' => [
442+
'value' => '2026-02-17 08:27:21.106',
443+
'expectedIso8601' => '2026-02-17T08:27:21+00:00'
444+
],
445+
'Single digit fraction' => [
446+
'value' => '2026-02-17 08:27:21.1',
447+
'expectedIso8601' => '2026-02-17T08:27:21+00:00'
448+
],
449+
'Midnight without microseconds' => [
450+
'value' => '2026-01-01 00:00:00',
451+
'expectedIso8601' => '2026-01-01T00:00:00+00:00'
452+
]
367453
];
368454
}
369455
}

0 commit comments

Comments
 (0)