Skip to content

Commit c463e1e

Browse files
committed
Introduce TimestampMilliseconds type and refactor DateTimeTimestamp to TimestampSeconds
- Added `TimestampMilliseconds` class for Unix timestamps in milliseconds, with strict validation and conversion utilities. - Refactored `DateTimeTimestamp` to `TimestampSeconds` for naming consistency and enhanced functionality. - Introduced `ReasonableRangeDateTimeTypeException` for range validation and improved error messaging. - Updated documentation (`README.md`, `USAGE.md`) and enhanced examples with new timestamp types. - Expanded unit tests to comprehensively cover parsing, formatting, and error scenarios for both milliseconds and seconds-based timestamps.
1 parent a992e39 commit c463e1e

File tree

9 files changed

+223
-32
lines changed

9 files changed

+223
-32
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ final class Profile
6565
public static function fromScalars(
6666
int $id,
6767
string $firstName,
68-
string $lastName,
68+
?string $lastName,
6969
): self {
7070
return new self(
7171
PositiveInt::fromInt($id),
@@ -78,6 +78,8 @@ final class Profile
7878
// Usage
7979
Profile::fromScalars(id: 101, firstName: 'Alice', lastName: 'Smith');
8080
Profile::fromScalars(id: 157, firstName: 'Tom', lastName: null);
81+
// From array
82+
$profile = Profile::fromScalars(...[157, 'Tom', null]);
8183
```
8284

8385

docs/USAGE.md

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,7 @@ Static usage examples
4141
---------------------
4242

4343
```php
44-
use PhpTypedValues\Integer\IntegerBasic;
45-
use PhpTypedValues\Integer\PositiveInt;
46-
use PhpTypedValues\Integer\NonNegativeInt;
47-
use PhpTypedValues\Integer\WeekDayInt;
48-
use PhpTypedValues\String\StringBasic;
49-
use PhpTypedValues\String\NonEmptyStr;
50-
use PhpTypedValues\Float\FloatBasic;
51-
use PhpTypedValues\Float\NonNegativeFloat;
52-
use PhpTypedValues\DateTime\DateTimeAtom;
53-
use PhpTypedValues\DateTime\DateTimeTimestamp;
44+
use PhpTypedValues\DateTime\DateTimeAtom;use PhpTypedValues\DateTime\Timestamp\TimestampSeconds;use PhpTypedValues\Float\FloatBasic;use PhpTypedValues\Float\NonNegativeFloat;use PhpTypedValues\Integer\IntegerBasic;use PhpTypedValues\Integer\NonNegativeInt;use PhpTypedValues\Integer\PositiveInt;use PhpTypedValues\Integer\WeekDayInt;use PhpTypedValues\String\NonEmptyStr;use PhpTypedValues\String\StringBasic;
5445

5546
// Integers
5647
$any = IntegerBasic::fromInt(-10);
@@ -74,7 +65,7 @@ $ratio = NonNegativeFloat::fromFloat(0.5); // >= 0
7465
$dt = DateTimeAtom::fromString('2025-01-02T03:04:05+00:00');
7566

7667
// DateTime (Unix timestamp, seconds)
77-
$unix = DateTimeTimestamp::fromString('1735787045');
68+
$unix = TimestampSeconds::fromString('1735787045');
7869

7970
// Accessing value and string form
8071
$posValue = $pos->value(); // 1 (int)

src/Code/DateTime/DateTimeType.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use DateTimeImmutable;
1010
use DateTimeZone;
1111
use PhpTypedValues\Code\Exception\DateTimeTypeException;
12+
use PhpTypedValues\Code\Exception\ReasonableRangeDateTimeTypeException;
1213

1314
use function count;
1415
use function sprintf;
@@ -20,11 +21,14 @@
2021
{
2122
protected const FORMAT = '';
2223
protected const ZONE = 'UTC';
24+
protected const MIN_TIMESTAMP_SECONDS = -62135596800; // 0001-01-01
25+
protected const MAX_TIMESTAMP_SECONDS = 253402300799; // 9999-12-31 23:59:59
2326

2427
protected DateTimeImmutable $value;
2528

2629
/**
2730
* @throws DateTimeTypeException
31+
* @throws ReasonableRangeDateTimeTypeException
2832
*/
2933
protected static function createFromFormat(
3034
string $value,
@@ -67,6 +71,16 @@ protected static function createFromFormat(
6771
throw new DateTimeTypeException(sprintf('Unexpected conversion, source string %s is not equal to formatted one %s', $value, $dt->format(static::FORMAT)));
6872
}
6973

74+
/**
75+
* Assert that timestamp in a reasonable range.
76+
*
77+
* @psalm-suppress PossiblyFalseReference
78+
*/
79+
$ts = $dt->format('U');
80+
if ($ts < static::MIN_TIMESTAMP_SECONDS || $ts > static::MAX_TIMESTAMP_SECONDS) {
81+
throw new ReasonableRangeDateTimeTypeException(sprintf('Timestamp "%s" out of supported range "%d"-"%d".', $ts, static::MIN_TIMESTAMP_SECONDS, static::MAX_TIMESTAMP_SECONDS));
82+
}
83+
7084
/**
7185
* $dt is not FALSE here, it will fail before on error checking.
7286
*
@@ -80,7 +94,13 @@ public function __construct(DateTimeImmutable $value)
8094
$this->value = $value;
8195
}
8296

83-
abstract public static function fromDateTime(DateTimeImmutable $value): self;
97+
public static function fromDateTime(DateTimeImmutable $value): static
98+
{
99+
// normalized timezone
100+
return new static(
101+
$value->setTimezone(new DateTimeZone(static::ZONE))
102+
);
103+
}
84104

85105
public function value(): DateTimeImmutable
86106
{
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\Code\Exception;
6+
7+
class ReasonableRangeDateTimeTypeException extends TypeException
8+
{
9+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\DateTime\Timestamp;
6+
7+
use DateTimeZone;
8+
use PhpTypedValues\Code\DateTime\DateTimeType;
9+
use PhpTypedValues\Code\Exception\DateTimeTypeException;
10+
11+
use function intdiv;
12+
use function sprintf;
13+
14+
/**
15+
* Unix timestamp in milliseconds since Unix epoch (UTC), e.g. "1732445696123".
16+
*
17+
* @psalm-immutable
18+
*/
19+
readonly class TimestampMilliseconds extends DateTimeType
20+
{
21+
/**
22+
* Internal formatting pattern for seconds + microseconds.
23+
*
24+
* @see https://www.php.net/manual/en/datetime.format.php
25+
*/
26+
protected const FORMAT = 'U.u';
27+
28+
/**
29+
* Parse from a numeric Unix timestamp string (milliseconds).
30+
*
31+
* @throws DateTimeTypeException
32+
*/
33+
public static function fromString(string $value): static
34+
{
35+
if (!ctype_digit($value)) {
36+
throw new DateTimeTypeException(sprintf('Expected milliseconds timestamp as digits, got "%s"', $value));
37+
}
38+
39+
// "1732445696123" -> 1732445696 seconds, 123 milliseconds
40+
$milliseconds = (int) $value;
41+
$seconds = intdiv($milliseconds, 1000);
42+
$msRemainder = $milliseconds % 1000;
43+
44+
// Convert the remainder to microseconds (pad to 3 digits, then * 1000)
45+
$microseconds = $msRemainder * 1000;
46+
47+
// Build "seconds.microseconds" string for INTERNAL_FORMAT, e.g. "1732445696.123000"
48+
$secondsWithMicro = sprintf('%d.%06d', $seconds, $microseconds);
49+
50+
return new static(
51+
static::createFromFormat(
52+
$secondsWithMicro,
53+
self::FORMAT,
54+
new DateTimeZone(static::ZONE)
55+
)
56+
);
57+
}
58+
59+
/**
60+
* Render as milliseconds since epoch, e.g. "1732445696123".
61+
*/
62+
public function toString(): string
63+
{
64+
$dt = $this->value();
65+
66+
$seconds = (int) $dt->format('U');
67+
$micros = (int) $dt->format('u');
68+
69+
$milliseconds = ($seconds * 1000) + intdiv($micros, 1000);
70+
71+
return (string) $milliseconds;
72+
}
73+
}

src/DateTime/DateTimeTimestamp.php renamed to src/DateTime/Timestamp/TimestampSeconds.php

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,18 @@
22

33
declare(strict_types=1);
44

5-
namespace PhpTypedValues\DateTime;
5+
namespace PhpTypedValues\DateTime\Timestamp;
66

7-
use DateTimeImmutable;
87
use DateTimeZone;
98
use PhpTypedValues\Code\DateTime\DateTimeType;
109
use PhpTypedValues\Code\Exception\DateTimeTypeException;
1110

1211
/**
13-
* Unix timestamp (seconds since Unix epoch, UTC).
12+
* Unix timestamp (seconds since Unix epoch, UTC), e.g. "1732445696".
1413
*
1514
* @psalm-immutable
1615
*/
17-
readonly class DateTimeTimestamp extends DateTimeType
16+
readonly class TimestampSeconds extends DateTimeType
1817
{
1918
/**
2019
* DateTime::format() pattern for Unix timestamp.
@@ -43,9 +42,4 @@ public function toString(): string
4342
{
4443
return $this->value()->format(static::FORMAT);
4544
}
46-
47-
public static function fromDateTime(DateTimeImmutable $value): static
48-
{
49-
return new static($value);
50-
}
5145
}

src/psalmTest.php

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111
use PhpTypedValues\DateTime\DateTimeAtom;
1212
use PhpTypedValues\DateTime\DateTimeRFC3339;
13-
use PhpTypedValues\DateTime\DateTimeTimestamp;
13+
use PhpTypedValues\DateTime\Timestamp\TimestampMilliseconds;
14+
use PhpTypedValues\DateTime\Timestamp\TimestampSeconds;
1415
use PhpTypedValues\Float\FloatBasic;
1516
use PhpTypedValues\Float\NonNegativeFloat;
1617
use PhpTypedValues\Integer\IntegerBasic;
@@ -20,6 +21,21 @@
2021
use PhpTypedValues\String\NonEmptyStr;
2122
use PhpTypedValues\String\StringBasic;
2223

24+
// try {
25+
// echo DateTimeImmutable::createFromFormat('U.u', '953402300800.000000')->format('U.u');
26+
// } catch (Throwable $e) {
27+
// var_dump('error');
28+
// var_dump($e);
29+
// }
30+
//
31+
// try {
32+
// echo TimestampMilliseconds::fromString('953402300800000')->toString(); // '253402300800000'
33+
// } catch (Throwable $e) {
34+
// var_dump('error');
35+
// var_dump($e);
36+
// }
37+
// exit('ssssssssss');
38+
2339
/**
2440
* Integer.
2541
*/
@@ -61,8 +77,11 @@
6177
echo DateTimeRFC3339::fromDateTime($dt)->toString() . \PHP_EOL;
6278

6379
// Timestamp
64-
$tsVo = DateTimeTimestamp::fromString('1735787045');
65-
echo DateTimeTimestamp::fromDateTime($tsVo->value())->toString() . \PHP_EOL;
80+
$tsVo = TimestampSeconds::fromString('1735787045');
81+
echo TimestampSeconds::fromDateTime($tsVo->value())->toString() . \PHP_EOL;
82+
83+
$tsVo = TimestampMilliseconds::fromString('1735787045123');
84+
echo TimestampMilliseconds::fromDateTime($tsVo->value())->toString() . \PHP_EOL;
6685

6786
/**
6887
* Artificial functions.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use PhpTypedValues\DateTime\Timestamp\TimestampMilliseconds;
6+
7+
it('fromString returns same instant and toString is milliseconds', function (): void {
8+
// 1,000,000,000,000 ms == 1,000,000,000 sec == 2001-09-09 01:46:40 UTC
9+
$dt = new DateTimeImmutable('2001-09-09 01:46:40');
10+
$vo = TimestampMilliseconds::fromString('1000000000000');
11+
12+
expect($vo->value()->format('U'))->toBe($dt->format('U'))
13+
->and($vo->toString())->toBe('1000000000000')
14+
->and($vo->value()->getTimezone()->getName())->toBe('+00:00');
15+
});
16+
17+
it('fromDateTime preserves instant and renders milliseconds (truncates microseconds)', function (): void {
18+
$dt = new DateTimeImmutable('2025-01-02T03:04:05.678+00:00');
19+
$vo = TimestampMilliseconds::fromDateTime($dt);
20+
21+
// Expected milliseconds: 1735787045 seconds and 678 ms
22+
expect($vo->value()->format('U'))->toBe('1735787045')
23+
->and($vo->toString())->toBe('1735787045678')
24+
->and($vo->value()->getTimezone()->getName())->toBe('UTC');
25+
});
26+
27+
it('fromDateTime normalizes timezone to UTC while preserving the instant', function (): void {
28+
// Source datetime has +03:00 offset, should be normalized to UTC internally
29+
$dt = new DateTimeImmutable('2025-01-02T03:04:05.123+03:00');
30+
$vo = TimestampMilliseconds::fromDateTime($dt);
31+
32+
// Unix timestamp in seconds must be equal regardless of timezone
33+
expect($vo->value()->format('U'))->toBe($dt->format('U'))
34+
->and($vo->value()->getTimezone()->getName())->toBe('UTC')
35+
// Milliseconds should reflect the source microseconds truncated to ms
36+
->and($vo->toString())->toBe((string) ((int) $dt->format('U') * 1000 + (int) ((int) $dt->format('u') / 1000)));
37+
});
38+
39+
it('fromString throws on non-digit input', function (): void {
40+
try {
41+
TimestampMilliseconds::fromString('not-a-number');
42+
expect()->fail('Exception was not thrown');
43+
} catch (Throwable $e) {
44+
expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\DateTimeTypeException::class)
45+
->and($e->getMessage())->toContain('Expected milliseconds timestamp as digits');
46+
}
47+
});
48+
49+
it('fromString throws on trailing data (non-digits)', function (): void {
50+
try {
51+
TimestampMilliseconds::fromString('1000000000000 ');
52+
expect()->fail('Exception was not thrown');
53+
} catch (Throwable $e) {
54+
expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\DateTimeTypeException::class)
55+
->and($e->getMessage())->toContain('Expected milliseconds timestamp as digits');
56+
}
57+
});
58+
59+
it('fromString throws when value is out of supported range (createFromFormat returns false)', function (): void {
60+
// 253402300800000 ms corresponds to 253402300800 seconds,
61+
// which is just beyond 9999-12-31T23:59:59Z (the typical max supported date).
62+
// This should make DateTimeImmutable::createFromFormat fail and return FALSE.
63+
$tooLargeMs = '253402300800000';
64+
65+
try {
66+
TimestampMilliseconds::fromString($tooLargeMs);
67+
expect()->fail('Exception was not thrown');
68+
} catch (Throwable $e) {
69+
expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\ReasonableRangeDateTimeTypeException::class)
70+
->and($e->getMessage())->toContain('Timestamp "253402300800" out of supported range "-62135596800"-"253402300799"')
71+
->and($e->getMessage())->toContain('253402300800');
72+
}
73+
});

0 commit comments

Comments
 (0)