Skip to content

Commit a8b4a4a

Browse files
committed
Refactor DateTimeBasic to DateTimeAtom, enhance strict validation, and expand unit tests
- Replaced `DateTimeBasic` with `DateTimeAtom` for improved naming clarity and adherence to the ATOM format. - Introduced strict validation logic in `DateTimeType` and `DateTimeAtom` with detailed error aggregation for parsing and formatting inconsistencies. - Added comprehensive unit tests to ensure robust exception handling for invalid input scenarios, such as trailing data, invalid offsets, and parse warnings. - Updated documentation (`README.md`, `USAGE.md`) and examples to reflect the `DateTimeAtom` changes. - Enhanced `psalmTest.php` with additional usage examples and error-handling demonstrations.
1 parent b482825 commit a8b4a4a

File tree

7 files changed

+231
-140
lines changed

7 files changed

+231
-140
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ PHP Typed Values
33

44
Typed value objects for common PHP data types. Make primitives explicit, safe, and self-documenting with tiny immutable value objects.
55

6+
[![Latest Version on Packagist](https://img.shields.io/packagist/v/georgii-web/php-typed-values.svg?style=flat-square)](https://packagist.org/packages/georgii-web/php-typed-values)
7+
[![Tests](https://github.com/georgii-web/php-typed-values/actions/workflows/php.yml/badge.svg)](https://github.com/georgii-web/php-typed-values/actions/workflows/php.yml)
8+
[![Total Downloads](https://img.shields.io/packagist/dt/georgii-web/php-typed-values.svg?style=flat-square)](https://packagist.org/packages/georgii-web/php-typed-values)
9+
610
- Requires PHP 8.2+
711
- Zero runtime dependencies
812
- Tooling: Pest, PHPUnit, Psalm, PHP-CS-Fixer

docs/USAGE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ $price = FloatBasic::fromString('19.99');
7070
$ratio = new NonNegativeFloat(0.5); // >= 0 allowed
7171

7272
// DateTime
73-
use PhpTypedValues\DateTime\DateTimeBasic;
73+
use PhpTypedValues\DateTime\DateTimeAtom;
7474

75-
$dt = DateTimeBasic::fromString('2025-01-02T03:04:05+00:00');
75+
$dt = DateTimeAtom::fromString('2025-01-02T03:04:05+00:00');
7676
echo $dt->toString(); // "2025-01-02T03:04:05+00:00"
7777

7878
// Accessing the raw value and string form
@@ -90,7 +90,7 @@ Invalid input throws an exception with a helpful message.
9090
use PhpTypedValues\Integer\PositiveInt;
9191
use PhpTypedValues\String\NonEmptyStr;
9292
use PhpTypedValues\Float\NonNegativeFloat;
93-
use PhpTypedValues\DateTime\DateTimeBasic;
93+
use PhpTypedValues\DateTime\DateTimeAtom;
9494

9595
new PositiveInt(0); // throws: Value must be a positive integer
9696
PositiveInt::fromString('12.3'); // throws: String has no valid integer
@@ -99,7 +99,7 @@ new NonEmptyStr(''); // throws: Value must be a non-empty string
9999

100100
NonNegativeFloat::fromString('abc'); // throws: String has no valid float
101101

102-
DateTimeBasic::fromString('not-a-date'); // throws: String has no valid datetime
102+
DateTimeAtom::fromString('not-a-date'); // throws: String has no valid datetime
103103
```
104104

105105
Notes

src/Code/DateTime/DateTimeType.php

Lines changed: 63 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,61 +4,91 @@
44

55
namespace PhpTypedValues\Code\DateTime;
66

7-
use const DATE_ATOM;
7+
use const PHP_EOL;
88

99
use DateTimeImmutable;
10-
use Exception;
10+
use DateTimeZone;
1111
use PhpTypedValues\Code\Exception\DateTimeTypeException;
1212

13+
use function count;
14+
use function sprintf;
15+
1316
/**
1417
* @psalm-immutable
1518
*/
1619
abstract readonly class DateTimeType implements DateTimeTypeInterface
1720
{
21+
protected const FORMAT = '';
22+
protected const ZONE = 'UTC';
23+
24+
protected DateTimeImmutable $value;
25+
1826
/**
1927
* @throws DateTimeTypeException
2028
*/
21-
protected static function parseString(string $value): DateTimeImmutable
22-
{
23-
// Accept a small, explicit set of formats to avoid accidentally parsing invalid strings as "now".
24-
$formats = [
25-
DATE_ATOM, // e.g., 2025-01-02T03:04:05+00:00
26-
'Y-m-d\TH:i:s\Z', // e.g., 2025-01-02T03:04:05Z
27-
'Y-m-d H:i:s', // e.g., 2025-01-02 03:04:05
28-
'!Y-m-d', // e.g., 2025-01-02 (force midnight by resetting unspecified fields)
29+
protected static function createFromFormat(
30+
string $value,
31+
string $format,
32+
?DateTimeZone $timezone = null,
33+
): DateTimeImmutable {
34+
/**
35+
* Collect errors and throw exception with all of them.
36+
*/
37+
$dt = DateTimeImmutable::createFromFormat($format, $value, $timezone);
38+
/**
39+
* Normalize getLastErrors result to an array with counters.
40+
* Some PHP versions return an array with zero counts instead of false.
41+
*/
42+
$errors = DateTimeImmutable::getLastErrors() ?: [
43+
'errors' => [],
44+
'warnings' => [],
2945
];
3046

31-
foreach ($formats as $format) {
32-
$dt = DateTimeImmutable::createFromFormat($format, $value);
33-
if ($dt instanceof DateTimeImmutable) {
34-
$errors = DateTimeImmutable::getLastErrors();
35-
$hasIssues = $errors !== false && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0);
36-
if (!$hasIssues) {
37-
return $dt;
38-
}
47+
if (count($errors['errors']) > 0 || count($errors['warnings']) > 0) {
48+
$errorMessages = '';
49+
50+
foreach ($errors['errors'] as $pos => $message) {
51+
$errorMessages .= sprintf('Error at %d: %s' . PHP_EOL, $pos, $message);
52+
}
53+
54+
foreach ($errors['warnings'] as $pos => $message) {
55+
$errorMessages .= sprintf('Warning at %d: %s' . PHP_EOL, $pos, $message);
3956
}
40-
}
4157

42-
// Fallback: try the engine parser for strict ISO-8601 only using DateTimeImmutable constructor
43-
try {
44-
$fallback = new DateTimeImmutable($value);
45-
} catch (Exception) {
46-
$fallback = null;
58+
throw new DateTimeTypeException(sprintf('Invalid date time value "%s", use ATOM format "%s"', $value, static::FORMAT) . PHP_EOL . $errorMessages);
4759
}
4860

49-
if ($fallback instanceof DateTimeImmutable) {
50-
// Heuristic: if input clearly looks like an ISO 8601 (contains 'T' and either 'Z' or timezone offset), accept it
51-
if (preg_match('/T\d{2}:\d{2}:\d{2}(?:Z|[+\-]\d{2}:?\d{2})$/', $value) === 1) {
52-
return $fallback;
53-
}
61+
/**
62+
* Strict “round-trip” check.
63+
*
64+
* @psalm-suppress PossiblyFalseReference
65+
*/
66+
if ($value !== $dt->format(static::FORMAT)) {
67+
throw new DateTimeTypeException(sprintf('Unexpected conversion, source string %s is not equal to formatted one %s', $value, $dt->format(static::FORMAT)));
5468
}
5569

56-
throw new DateTimeTypeException('String has no valid datetime');
70+
/**
71+
* $dt is not FALSE here, it will fail before on error checking.
72+
*
73+
* @psalm-suppress FalsableReturnStatement
74+
*/
75+
return $dt;
76+
}
77+
78+
public function __construct(DateTimeImmutable $value)
79+
{
80+
$this->value = $value;
81+
}
82+
83+
abstract public static function fromDateTime(DateTimeImmutable $value): self;
84+
85+
public function value(): DateTimeImmutable
86+
{
87+
return $this->value;
5788
}
5889

59-
public function toString(): string
90+
public static function getFormat(): string
6091
{
61-
// ISO 8601 string representation
62-
return $this->value()->format(DATE_ATOM);
92+
return static::FORMAT;
6393
}
6494
}
Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,43 @@
44

55
namespace PhpTypedValues\DateTime;
66

7+
use const DATE_ATOM;
8+
79
use DateTimeImmutable;
10+
use DateTimeZone;
811
use PhpTypedValues\Code\DateTime\DateTimeType;
912
use PhpTypedValues\Code\Exception\DateTimeTypeException;
1013

1114
/**
15+
* ATOM RFC 3339 format based on ISO 8601.
16+
*
1217
* @psalm-immutable
1318
*/
14-
final readonly class DateTimeBasic extends DateTimeType
19+
final readonly class DateTimeAtom extends DateTimeType
1520
{
16-
protected DateTimeImmutable $value;
17-
18-
public function __construct(DateTimeImmutable $value)
19-
{
20-
$this->value = $value;
21-
}
22-
23-
public static function fromDateTime(DateTimeImmutable $value): self
24-
{
25-
return new self($value);
26-
}
21+
protected const FORMAT = DATE_ATOM;
2722

2823
/**
2924
* @throws DateTimeTypeException
3025
*/
3126
public static function fromString(string $value): self
3227
{
33-
$dt = parent::parseString($value);
28+
return new self(
29+
self::createFromFormat(
30+
$value,
31+
self::FORMAT,
32+
new DateTimeZone(self::ZONE)
33+
)
34+
);
35+
}
3436

35-
return new self($dt);
37+
public function toString(): string
38+
{
39+
return $this->value()->format(self::FORMAT);
3640
}
3741

38-
public function value(): DateTimeImmutable
42+
public static function fromDateTime(DateTimeImmutable $value): self
3943
{
40-
return $this->value;
44+
return new self($value);
4145
}
4246
}

src/psalmTest.php

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
require_once 'vendor/autoload.php';
1010

11-
use PhpTypedValues\DateTime\DateTimeBasic;
11+
use PhpTypedValues\DateTime\DateTimeAtom;
1212
use PhpTypedValues\Float\FloatBasic;
1313
use PhpTypedValues\Float\NonNegativeFloat;
1414
use PhpTypedValues\Integer\IntegerBasic;
@@ -26,32 +26,41 @@
2626
testNonNegativeInt(NonNegativeInt::fromInt(10)->value());
2727
testWeekDayInt(WeekDayInt::fromInt(7)->value());
2828

29-
echo IntegerBasic::fromString('10')->toString();
29+
echo IntegerBasic::fromString('10')->toString() . \PHP_EOL;
3030

3131
/**
3232
* String.
3333
*/
3434
testString(StringBasic::fromString('hi')->value());
3535
testNonEmptyString(NonEmptyStr::fromString('hi')->value());
3636

37-
echo StringBasic::fromString('hi')->toString();
37+
echo StringBasic::fromString('hi')->toString() . \PHP_EOL;
3838

3939
/**
4040
* Float.
4141
*/
4242
testFloat(FloatBasic::fromFloat(3.14)->value());
4343

44-
echo FloatBasic::fromString('2.71828')->toString();
44+
echo FloatBasic::fromString('2.71828')->toString() . \PHP_EOL;
4545

4646
// PositiveFloat usage
4747
testPositiveFloat(NonNegativeFloat::fromFloat(0.5)->value());
48-
echo NonNegativeFloat::fromString('3.14159')->toString();
48+
echo NonNegativeFloat::fromString('3.14159')->toString() . \PHP_EOL;
4949

5050
/**
5151
* DateTime.
5252
*/
53-
$dt = DateTimeBasic::fromString('2025-01-02T03:04:05+00:00')->value();
54-
echo DateTimeBasic::fromDateTime($dt)->toString();
53+
echo DateTimeAtom::getFormat() . \PHP_EOL;
54+
55+
$dt = DateTimeAtom::fromString('2025-01-02T03:04:05+00:00')->value();
56+
echo DateTimeAtom::fromDateTime($dt)->toString() . \PHP_EOL;
57+
58+
try {
59+
$dt = DateTimeAtom::fromString('2025-12-02T03:04:05+ 00:00')->value();
60+
echo DateTimeAtom::fromDateTime($dt)->toString() . \PHP_EOL;
61+
} catch (Throwable $e) {
62+
var_export($e);
63+
}
5564

5665
/**
5766
* Artificial functions.

tests/Unit/Code/DateTime/DateTimeTypeTest.php

Lines changed: 10 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -2,93 +2,23 @@
22

33
declare(strict_types=1);
44

5-
use PhpTypedValues\Code\Exception\DateTimeTypeException;
6-
use PhpTypedValues\DateTime\DateTimeBasic;
5+
use PhpTypedValues\DateTime\DateTimeAtom;
76

87
it('fromDateTime returns same instant and toString is ISO 8601', function (): void {
98
$dt = new DateTimeImmutable('2025-01-02T03:04:05+00:00');
10-
$vo = DateTimeBasic::fromDateTime($dt);
9+
$vo = DateTimeAtom::fromString('2025-01-02T03:04:05+00:00');
1110

12-
expect($vo->value()->format(\DATE_ATOM))->toBe('2025-01-02T03:04:05+00:00')
11+
expect($dt->format(\DATE_ATOM))->toBe('2025-01-02T03:04:05+00:00')
1312
->and($vo->toString())->toBe('2025-01-02T03:04:05+00:00');
1413
});
1514

16-
it('fromString parses several common formats', function (): void {
17-
expect(DateTimeBasic::fromString('2025-01-02T03:04:05+00:00')->toString())
18-
->toBe('2025-01-02T03:04:05+00:00');
19-
20-
// Zulu timezone
21-
$z = DateTimeBasic::fromString('2025-01-02T03:04:05Z');
22-
expect($z->value()->format('Y-m-d\TH:i:s\Z'))->toBe('2025-01-02T03:04:05Z');
23-
24-
// Date only defaults to midnight in current timezone; ensure parse succeeds
25-
$d = DateTimeBasic::fromString('2025-01-02');
26-
expect($d->value())->toBeInstanceOf(DateTimeImmutable::class);
27-
28-
// Space-separated format
29-
$s = DateTimeBasic::fromString('2025-01-02 03:04:05');
30-
expect($s->value())->toBeInstanceOf(DateTimeImmutable::class);
31-
});
32-
33-
it('fromString rejects invalid strings', function (): void {
34-
foreach (['', 'not-a-date', '2025-13-01', '2025-01-32', '2025-01-02T25:00:00'] as $invalid) {
35-
expect(fn() => DateTimeBasic::fromString($invalid))
36-
->toThrow(DateTimeTypeException::class);
37-
}
38-
});
39-
40-
it('parses ISO 8601 without colon in offset via fallback and normalizes output', function (): void {
41-
// Not DATE_ATOM due to +0000 (no colon); should be accepted by fallback and normalized to +00:00
42-
$vo = DateTimeBasic::fromString('2025-01-02T03:04:05+0000');
43-
expect($vo->toString())->toBe('2025-01-02T03:04:05+00:00');
44-
});
45-
46-
it('parses ISO 8601 with negative offset without colon via fallback and normalizes output', function (): void {
47-
// Example with -02:30 offset written without colon; fallback should accept and normalize to -02:30
48-
$vo = DateTimeBasic::fromString('2025-01-02T03:04:05-0230');
49-
expect($vo->toString())->toBe('2025-01-02T03:04:05-02:30');
50-
});
51-
52-
it('parses ISO 8601 with positive offset without colon via fallback and normalizes output', function (): void {
53-
// Example with +02:30 offset written without colon; fallback should accept and normalize to +02:30
54-
$vo = DateTimeBasic::fromString('2025-01-02T03:04:05+0230');
55-
expect($vo->toString())->toBe('2025-01-02T03:04:05+02:30');
56-
});
57-
58-
it('rejects RFC 2822 even though PHP can parse it (guarded fallback)', function (): void {
59-
// Parsed by DateTimeImmutable, but lacks the strict ISO-8601 heuristic (no "T" separator)
60-
expect(fn() => DateTimeBasic::fromString('Thu, 02 Jan 2025 03:04:05 +0000'))
61-
->toThrow(DateTimeTypeException::class);
62-
});
63-
64-
it('rejects strings that createFromFormat parses with warnings (trailing data)', function (): void {
65-
// Matches 'Y-m-d H:i:s' but with trailing timezone token causing warnings; fallback will also reject (no 'T')
66-
expect(fn() => DateTimeBasic::fromString('2025-01-02 03:04:05 UTC'))
67-
->toThrow(DateTimeTypeException::class);
68-
});
69-
70-
it('rejects DATE_ATOM with trailing data (warnings from createFromFormat)', function (): void {
71-
// Matches DATE_ATOM except for trailing junk which should produce warnings; must be rejected
72-
expect(fn() => DateTimeBasic::fromString('2025-01-02T03:04:05+00:00junk'))
73-
->toThrow(DateTimeTypeException::class);
74-
});
75-
76-
it('rejects Z-format with trailing data (warnings from createFromFormat)', function (): void {
77-
// Matches explicit Z format with extra trailing characters; must be rejected
78-
expect(fn() => DateTimeBasic::fromString('2025-01-02T03:04:05Zjunk'))
79-
->toThrow(DateTimeTypeException::class);
80-
});
81-
82-
it('normalizes Zulu input to +00:00 via toString()', function (): void {
83-
// Parsed by the explicit 'Y-m-d\\TH:i:s\\Z' format, toString should output DATE_ATOM with +00:00
84-
$vo = DateTimeBasic::fromString('2025-01-02T03:04:05Z');
85-
expect($vo->toString())->toBe('2025-01-02T03:04:05+00:00');
15+
it('DateTimeImmutable has false and throws an exception', function (): void {
16+
expect(
17+
fn() => DateTimeAtom::fromString('')
18+
)->toThrow(PhpTypedValues\Code\Exception\DateTimeTypeException::class);
8619
});
8720

88-
it('date-only input produces midnight instant and serializes via toString()', function (): void {
89-
$vo = DateTimeBasic::fromString('2025-01-02');
90-
// We cannot assert the exact offset because it depends on the environment timezone,
91-
// but we can assert that toString() returns an ISO-8601 string for the same day and midnight time.
92-
$str = $vo->toString();
93-
expect($str)->toStartWith('2025-01-02T00:00:00');
21+
it('throws DateTimeTypeException on unexpected conversion when input uses Z instead of +00:00', function (): void {
22+
$call = fn() => DateTimeAtom::fromString('2025-01-02T03:04:05Z');
23+
expect($call)->toThrow(PhpTypedValues\Code\Exception\DateTimeTypeException::class);
9424
});

0 commit comments

Comments
 (0)