Skip to content

Commit 4feec32

Browse files
committed
Introduce DateTimeRFC3339, DateTimeRFC3339Extended, and DateTimeW3C types with validation and comprehensive tests
- Added new `DateTimeRFC3339`, `DateTimeRFC3339Extended`, and `DateTimeW3C` classes for strict date-time validation based on respective standards. - Enhanced `DateTimeType` to support multiple formats and improve error messaging. - Implemented comprehensive unit tests for validation scenarios, including invalid formats, trailing data, and parse warnings. - Updated `psalmTest.php` with examples demonstrating new date-time types.
1 parent bcaf045 commit 4feec32

File tree

10 files changed

+490
-6
lines changed

10 files changed

+490
-6
lines changed

src/Code/DateTime/DateTimeType.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ protected static function createFromFormat(
5555
$errorMessages .= sprintf('Warning at %d: %s' . PHP_EOL, $pos, $message);
5656
}
5757

58-
throw new DateTimeTypeException(sprintf('Invalid date time value "%s", use ATOM format "%s"', $value, static::FORMAT) . PHP_EOL . $errorMessages);
58+
throw new DateTimeTypeException(sprintf('Invalid date time value "%s", use format "%s"', $value, static::FORMAT) . PHP_EOL . $errorMessages);
5959
}
6060

6161
/**

src/DateTime/DateTimeAtom.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public static function fromString(string $value): self
2828
return new self(
2929
self::createFromFormat(
3030
$value,
31-
self::FORMAT,
31+
static::FORMAT,
3232
new DateTimeZone(self::ZONE)
3333
)
3434
);

src/DateTime/DateTimeRFC3339.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\DateTime;
6+
7+
use const DATE_RFC3339;
8+
9+
use DateTimeImmutable;
10+
use DateTimeZone;
11+
use PhpTypedValues\Code\DateTime\DateTimeType;
12+
use PhpTypedValues\Code\Exception\DateTimeTypeException;
13+
14+
/**
15+
* RFC 3339 format based on ISO 8601.
16+
*
17+
* @psalm-immutable
18+
*/
19+
readonly class DateTimeRFC3339 extends DateTimeType
20+
{
21+
protected const FORMAT = DATE_RFC3339;
22+
23+
/**
24+
* @throws DateTimeTypeException
25+
*/
26+
public static function fromString(string $value): self
27+
{
28+
return new self(
29+
self::createFromFormat(
30+
$value,
31+
static::FORMAT,
32+
new DateTimeZone(self::ZONE)
33+
)
34+
);
35+
}
36+
37+
public function toString(): string
38+
{
39+
return $this->value()->format(self::FORMAT);
40+
}
41+
42+
public static function fromDateTime(DateTimeImmutable $value): self
43+
{
44+
return new self($value);
45+
}
46+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\DateTime;
6+
7+
use const DATE_RFC3339_EXTENDED;
8+
9+
use DateTimeImmutable;
10+
use DateTimeZone;
11+
use PhpTypedValues\Code\DateTime\DateTimeType;
12+
use PhpTypedValues\Code\Exception\DateTimeTypeException;
13+
14+
/**
15+
* RFC 3339 EXTENDED format based on ISO 8601.
16+
*
17+
* @psalm-immutable
18+
*/
19+
readonly class DateTimeRFC3339Extended extends DateTimeType
20+
{
21+
protected const FORMAT = DATE_RFC3339_EXTENDED;
22+
23+
/**
24+
* @throws DateTimeTypeException
25+
*/
26+
public static function fromString(string $value): self
27+
{
28+
return new self(
29+
self::createFromFormat(
30+
$value,
31+
static::FORMAT,
32+
new DateTimeZone(self::ZONE)
33+
)
34+
);
35+
}
36+
37+
public function toString(): string
38+
{
39+
return $this->value()->format(self::FORMAT);
40+
}
41+
42+
public static function fromDateTime(DateTimeImmutable $value): self
43+
{
44+
return new self($value);
45+
}
46+
}

src/DateTime/DateTimeW3C.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpTypedValues\DateTime;
6+
7+
use const DATE_W3C;
8+
9+
use DateTimeImmutable;
10+
use DateTimeZone;
11+
use PhpTypedValues\Code\DateTime\DateTimeType;
12+
use PhpTypedValues\Code\Exception\DateTimeTypeException;
13+
14+
/**
15+
* W3C RFC 3339 format based on ISO 8601.
16+
*
17+
* @psalm-immutable
18+
*/
19+
readonly class DateTimeW3C extends DateTimeType
20+
{
21+
protected const FORMAT = DATE_W3C;
22+
23+
/**
24+
* @throws DateTimeTypeException
25+
*/
26+
public static function fromString(string $value): self
27+
{
28+
return new self(
29+
self::createFromFormat(
30+
$value,
31+
static::FORMAT,
32+
new DateTimeZone(self::ZONE)
33+
)
34+
);
35+
}
36+
37+
public function toString(): string
38+
{
39+
return $this->value()->format(self::FORMAT);
40+
}
41+
42+
public static function fromDateTime(DateTimeImmutable $value): self
43+
{
44+
return new self($value);
45+
}
46+
}

src/psalmTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require_once 'vendor/autoload.php';
1010

1111
use PhpTypedValues\DateTime\DateTimeAtom;
12+
use PhpTypedValues\DateTime\DateTimeRFC3339;
1213
use PhpTypedValues\Float\FloatBasic;
1314
use PhpTypedValues\Float\NonNegativeFloat;
1415
use PhpTypedValues\Integer\IntegerBasic;
@@ -55,6 +56,9 @@
5556
$dt = DateTimeAtom::fromString('2025-01-02T03:04:05+00:00')->value();
5657
echo DateTimeAtom::fromDateTime($dt)->toString() . \PHP_EOL;
5758

59+
$dt = DateTimeRFC3339::fromString('2025-01-02T03:04:05+00:00')->value();
60+
echo DateTimeRFC3339::fromDateTime($dt)->toString() . \PHP_EOL;
61+
5862
/**
5963
* Artificial functions.
6064
*/

tests/Unit/DateTime/DateTimeAtomTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,14 @@
6464
$msg = $e->getMessage();
6565
// Header should be present and terminated with a newline
6666
$expectedHeader = \sprintf(
67-
'Invalid date time value "%s", use ATOM format "%s"',
67+
'Invalid date time value "%s", use format "%s"',
6868
$input,
6969
\DATE_ATOM
7070
) . \PHP_EOL;
7171

7272
expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\DateTimeTypeException::class)
7373
->and($msg)->toContain($expectedHeader)
74-
->and($msg)->toContain('Invalid date time value "2025-13-40T25:61:61+00:00", use ATOM format "Y-m-d\TH:i:sP"
74+
->and($msg)->toContain('Invalid date time value "2025-13-40T25:61:61+00:00", use format "Y-m-d\TH:i:sP"
7575
Warning at 25: The parsed date was invalid
7676
')
7777
// No injected garbage from the mutation should appear
@@ -88,7 +88,7 @@
8888
$msg = $e->getMessage();
8989
// must contain the header + newline and at least one warning line ending with newline
9090
expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\DateTimeTypeException::class)
91-
->and($msg)->toContain('Invalid date time value "2025-01-02T03:04:05+00:00 ", use ATOM format "Y-m-d\TH:i:sP"
91+
->and($msg)->toContain('Invalid date time value "2025-01-02T03:04:05+00:00 ", use format "Y-m-d\TH:i:sP"
9292
Error at 25: Trailing data
9393
');
9494

@@ -106,7 +106,7 @@
106106
$msg = $e->getMessage();
107107
// must contain the header + newline and at least one warning line ending with newline
108108
expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\DateTimeTypeException::class)
109-
->and($msg)->toContain('Invalid date time value "2025-12-02T03:04:05+ 00:00", use ATOM format "Y-m-d\TH:i:sP"
109+
->and($msg)->toContain('Invalid date time value "2025-12-02T03:04:05+ 00:00", use format "Y-m-d\TH:i:sP"
110110
Error at 19: The timezone could not be found in the database
111111
Error at 20: Trailing data
112112
');
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use PhpTypedValues\DateTime\DateTimeRFC3339Extended;
6+
7+
it('fromDateTime returns same instant and toString is ISO 8601', function (): void {
8+
$dt = new DateTimeImmutable('2025-01-02T03:04:05+00:00');
9+
$vo = DateTimeRFC3339Extended::fromDateTime($dt);
10+
11+
expect($vo->value()->format(\DATE_RFC3339_EXTENDED))->toBe('2025-01-02T03:04:05.000+00:00')
12+
->and($vo->toString())->toBe('2025-01-02T03:04:05.000+00:00');
13+
});
14+
15+
it('fromString parses valid RFC3339 and preserves timezone offset', function (): void {
16+
$vo = DateTimeRFC3339Extended::fromString('2030-12-31T23:59:59.000+03:00');
17+
18+
expect($vo->toString())->toBe('2030-12-31T23:59:59.000+03:00')
19+
->and($vo->value()->format(\DATE_RFC3339_EXTENDED))->toBe('2030-12-31T23:59:59.000+03:00');
20+
});
21+
22+
it('fromString throws on invalid date parts (errors path)', function (): void {
23+
// invalid month 13 produces errors
24+
$call = fn() => DateTimeRFC3339Extended::fromString('2025-13-02T03:04:05.000+00:00');
25+
expect($call)->toThrow(PhpTypedValues\Code\Exception\DateTimeTypeException::class);
26+
});
27+
28+
it('fromString throws on trailing data (warnings path)', function (): void {
29+
// trailing space should trigger a warning from DateTime::getLastErrors()
30+
try {
31+
DateTimeRFC3339Extended::fromString('2025-01-02T03:04:05.000+00:00 ');
32+
expect()->fail('Exception was not thrown');
33+
} catch (Throwable $e) {
34+
expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\DateTimeTypeException::class)
35+
->and($e->getMessage())->toContain('Invalid date time value');
36+
}
37+
});
38+
39+
it('getFormat returns RFC3339 format', function (): void {
40+
expect(DateTimeRFC3339Extended::getFormat())->toBe(\DATE_RFC3339_EXTENDED);
41+
});
42+
43+
it('fromString with both parse error and warning includes both details in the exception message', function (): void {
44+
// invalid month (error) + trailing space (warning)
45+
$input = '2025-13-02T03:04:05.000+00:00 ';
46+
try {
47+
DateTimeRFC3339Extended::fromString($input);
48+
expect()->fail('Exception was not thrown');
49+
} catch (Throwable $e) {
50+
expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\DateTimeTypeException::class)
51+
->and($e->getMessage())->toContain('Invalid date time value')
52+
->and($e->getMessage())->toContain('Error at')
53+
->and($e->getMessage())->toContain('Warning at');
54+
}
55+
});
56+
57+
it('fromString aggregates multiple parse warnings with proper concatenation and line breaks', function (): void {
58+
// Multiple invalid components to force several distinct parse errors
59+
$input = '2025-13-40T25:61:61.000+00:00';
60+
try {
61+
DateTimeRFC3339Extended::fromString($input);
62+
expect()->fail('Exception was not thrown');
63+
} catch (Throwable $e) {
64+
$msg = $e->getMessage();
65+
// Header should be present and terminated with a newline
66+
$expectedHeader = \sprintf(
67+
'Invalid date time value "%s", use format "%s"',
68+
$input,
69+
\DATE_RFC3339_EXTENDED
70+
) . \PHP_EOL;
71+
72+
expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\DateTimeTypeException::class)
73+
->and($msg)->toContain($expectedHeader)
74+
->and($msg)->toContain('Invalid date time value "2025-13-40T25:61:61.000+00:00", use format "Y-m-d\TH:i:s.vP"
75+
Warning at 29: The parsed date was invalid
76+
')
77+
// No injected garbage from the mutation should appear
78+
->and($msg)->not->toContain('PEST Mutator was here!');
79+
}
80+
});
81+
82+
it('errors-only path keeps newline after header and after error line', function (): void {
83+
$input = '2025-01-02T03:04:05.000+00:00 ';
84+
try {
85+
DateTimeRFC3339Extended::fromString($input);
86+
expect()->fail('Exception was not thrown');
87+
} catch (Throwable $e) {
88+
$msg = $e->getMessage();
89+
// must contain the header + newline and at least one warning line ending with newline
90+
expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\DateTimeTypeException::class)
91+
->and($msg)->toContain('Invalid date time value "2025-01-02T03:04:05.000+00:00 ", use format "Y-m-d\TH:i:s.vP"
92+
Error at 29: Trailing data
93+
');
94+
95+
// Count total newlines: one after header + one after the warning line => at least 2
96+
expect(substr_count($msg, \PHP_EOL))->toBeGreaterThanOrEqual(2);
97+
}
98+
});
99+
100+
it('double error message', function (): void {
101+
$input = '2025-12-02T03:04:05.000+ 00:00';
102+
try {
103+
DateTimeRFC3339Extended::fromString($input);
104+
expect()->fail('Exception was not thrown');
105+
} catch (Throwable $e) {
106+
$msg = $e->getMessage();
107+
// must contain the header + newline and at least one warning line ending with newline
108+
expect($e)->toBeInstanceOf(PhpTypedValues\Code\Exception\DateTimeTypeException::class)
109+
->and($msg)->toContain('Invalid date time value "2025-12-02T03:04:05.000+ 00:00", use format "Y-m-d\TH:i:s.vP"
110+
Error at 23: The timezone could not be found in the database
111+
Error at 24: Trailing data
112+
');
113+
}
114+
});

0 commit comments

Comments
 (0)