Skip to content

Commit cc6d4bb

Browse files
committed
Adding a ClockField to represents time without timezone
1 parent d91be2d commit cc6d4bb

11 files changed

Lines changed: 349 additions & 19 deletions

src/Schema/ClockField.php

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
/**
4+
* League.Csv (https://csv.thephpleague.com)
5+
*
6+
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace League\Csv\Schema;
15+
16+
use ValueError;
17+
18+
use function array_map;
19+
use function ctype_digit;
20+
use function implode;
21+
use function is_string;
22+
use function preg_match;
23+
use function strlen;
24+
use function trim;
25+
26+
final class ClockField extends FieldEvaluator implements Field
27+
{
28+
/** @var non-empty-string */
29+
private readonly string $pattern;
30+
31+
private function __construct(
32+
public readonly string $separator,
33+
public readonly ClockPrecision $clockPrecision,
34+
public readonly ClockStyle $clockStyle,
35+
float $confidenceThreshold = 0.8
36+
) {
37+
(1 === strlen($separator) && !ctype_digit($this->separator)) || throw new ValueError('The separator character must be a non-empty single byte string.');
38+
39+
parent::__construct($confidenceThreshold);
40+
41+
$this->pattern = $this->generatePattern();
42+
}
43+
44+
public static function seconds(string $separator = ':', ClockStyle $clockStyle = ClockStyle::Padded, float $confidenceThreshold = 0.8): self
45+
{
46+
return new self($separator, ClockPrecision::HoursMinutesSeconds, $clockStyle, $confidenceThreshold);
47+
}
48+
49+
public static function minutes(string $separator = ':', ClockStyle $clockStyle = ClockStyle::Padded, float $confidenceThreshold = 0.8): self
50+
{
51+
return new self($separator, ClockPrecision::HoursMinutes, $clockStyle, $confidenceThreshold);
52+
}
53+
54+
public static function hours(string $separator = ':', ClockStyle $clockStyle = ClockStyle::Padded, float $confidenceThreshold = 0.8): self
55+
{
56+
return new self($separator, ClockPrecision::Hours, $clockStyle, $confidenceThreshold);
57+
}
58+
59+
public function type(): FieldType
60+
{
61+
return FieldType::Time;
62+
}
63+
64+
public function metadata(): FieldMetadata
65+
{
66+
return new FieldMetadata();
67+
}
68+
69+
public function name(): string
70+
{
71+
$precision = match ($this->clockPrecision) {
72+
ClockPrecision::Hours => 'hours',
73+
ClockPrecision::HoursMinutes => 'hours_minutes',
74+
ClockPrecision::HoursMinutesSeconds => 'hours_minutes_seconds',
75+
};
76+
77+
$style = match ($this->clockStyle) {
78+
ClockStyle::NonPadded => 'non_padded',
79+
ClockStyle::Padded => 'padded',
80+
};
81+
82+
return FieldType::Time->value.'(precision='.$precision.',style='.$style.',separator='.$this->separator.')';
83+
}
84+
85+
public function parse(mixed $value): ?string
86+
{
87+
if (!is_string($value)) {
88+
return null;
89+
}
90+
91+
$value = trim($value);
92+
if (1 !== preg_match($this->pattern, $value, $found)) {
93+
return null;
94+
}
95+
96+
$hour = (int) $found['hour'];
97+
$minute = (int) ($found['minute'] ?? 0);
98+
$second = (int) ($found['second'] ?? 0);
99+
100+
return ($hour > 23 || $minute > 59 || $second > 59)
101+
? null
102+
: $this->formatTimePart($hour)
103+
.$this->separator
104+
.$this->formatTimePart($minute)
105+
.$this->separator
106+
.$this->formatTimePart($second);
107+
}
108+
109+
private function formatTimePart(int $value): string
110+
{
111+
return ($value < 10 ? '0' : '').$value;
112+
}
113+
114+
/**
115+
* @return non-empty-string
116+
*/
117+
private function generatePattern(): string
118+
{
119+
$digit = fn () => ClockStyle::Padded === $this->clockStyle ? '\d{2}' : '\d{1,2}';
120+
121+
$patternParts = array_map(
122+
fn (string $part): string => "(?<{$part}>".$digit().')',
123+
match ($this->clockPrecision) {
124+
ClockPrecision::Hours => ['hour'],
125+
ClockPrecision::HoursMinutes => ['hour', 'minute'],
126+
ClockPrecision::HoursMinutesSeconds => ['hour', 'minute', 'second'],
127+
}
128+
);
129+
130+
return '/^'.implode($this->separator, $patternParts).'$/';
131+
}
132+
}

src/Schema/ClockFieldTest.php

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
3+
/**
4+
* League.Csv (https://csv.thephpleague.com)
5+
*
6+
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace League\Csv\Schema;
15+
16+
use PHPUnit\Framework\TestCase;
17+
18+
final class ClockFieldTest extends TestCase
19+
{
20+
public function test_hours_constructor_parses_correctly(): void
21+
{
22+
$field = ClockField::hours();
23+
24+
self::assertSame('time(precision=hours,style=padded,separator=:)', $field->name());
25+
26+
self::assertSame('10:00:00', $field->parse('10'));
27+
self::assertSame('23:00:00', $field->parse('23'));
28+
}
29+
30+
public function test_minutes_constructor_parses_correctly(): void
31+
{
32+
$field = ClockField::minutes(separator: '.');
33+
34+
self::assertSame('time(precision=hours_minutes,style=padded,separator=.)', $field->name());
35+
36+
self::assertSame('10.30.00', $field->parse('10.30'));
37+
self::assertSame('23.59.00', $field->parse('23.59'));
38+
}
39+
40+
public function test_seconds_constructor_parses_correctly(): void
41+
{
42+
$field = ClockField::seconds();
43+
44+
self::assertSame('time(precision=hours_minutes_seconds,style=padded,separator=:)', $field->name());
45+
46+
self::assertSame('10:30:45', $field->parse('10:30:45'));
47+
self::assertSame('00:00:00', $field->parse('00:00:00'));
48+
}
49+
50+
public function test_invalid_string_returns_null(): void
51+
{
52+
$field = ClockField::seconds();
53+
54+
self::assertNull($field->parse(''));
55+
self::assertNull($field->parse(' '));
56+
self::assertNull($field->parse('invalid'));
57+
}
58+
59+
public function test_non_string_returns_null(): void
60+
{
61+
$field = ClockField::seconds();
62+
63+
self::assertNull($field->parse(null));
64+
self::assertNull($field->parse(123));
65+
self::assertNull($field->parse([]));
66+
}
67+
68+
public function test_seconds_precision_rejects_invalid_time(): void
69+
{
70+
$field = ClockField::seconds();
71+
72+
self::assertNull($field->parse('25:00:00')); // invalid hour
73+
self::assertNull($field->parse('10:70:00')); // invalid minute
74+
self::assertNull($field->parse('10:00:90')); // invalid second
75+
}
76+
77+
public function test_minutes_precision_rejects_seconds_input(): void
78+
{
79+
$field = ClockField::minutes();
80+
81+
self::assertNull($field->parse('10:30:45')); // too precise
82+
}
83+
84+
public function test_hours_precision_rejects_minutes_input(): void
85+
{
86+
$field = ClockField::hours();
87+
88+
self::assertNull($field->parse('10:30')); // too precise
89+
self::assertNull($field->parse('10:30:45'));
90+
}
91+
92+
public function test_output_is_always_normalized_to_his(): void
93+
{
94+
$field = ClockField::seconds(clockStyle: ClockStyle::NonPadded);
95+
96+
self::assertSame('01:02:03', $field->parse('1:2:3'));
97+
}
98+
99+
public function test_metadata_contains_format(): void
100+
{
101+
$field = ClockField::seconds();
102+
103+
self::assertSame([], $field->metadata()->all());
104+
}
105+
106+
public function test_name_contains_format(): void
107+
{
108+
self::assertSame(
109+
'time(precision=hours_minutes_seconds,style=padded,separator=:)',
110+
ClockField::seconds()->name()
111+
);
112+
113+
self::assertSame(
114+
'time(precision=hours_minutes,style=padded,separator=:)',
115+
ClockField::minutes()->name()
116+
);
117+
118+
self::assertSame(
119+
'time(precision=hours,style=padded,separator=:)',
120+
ClockField::hours()->name()
121+
);
122+
}
123+
}

src/Schema/ClockPrecision.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
/**
4+
* League.Csv (https://csv.thephpleague.com)
5+
*
6+
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace League\Csv\Schema;
15+
16+
enum ClockPrecision
17+
{
18+
case Hours;
19+
case HoursMinutes;
20+
case HoursMinutesSeconds;
21+
}

src/Schema/ClockStyle.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/**
4+
* League.Csv (https://csv.thephpleague.com)
5+
*
6+
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace League\Csv\Schema;
15+
16+
enum ClockStyle
17+
{
18+
case Padded;
19+
case NonPadded;
20+
}

src/Schema/CustomField.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public function type(): FieldType
5252

5353
public function name(): string
5454
{
55-
return $this->fieldTypeName;
55+
return FieldType::Custom->value.'('.$this->fieldTypeName.')';
5656
}
5757

5858
/**
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
use function is_subclass_of;
2626
use function trim;
2727

28-
final class DateField extends FieldEvaluator implements Field
28+
final class DateTimeField extends FieldEvaluator implements Field
2929
{
3030
/** @var non-empty-string */
3131
public readonly string $format;
@@ -41,6 +41,7 @@ final class DateField extends FieldEvaluator implements Field
4141
DateTimeInterface::RFC3339,
4242
DateTimeInterface::RFC3339_EXTENDED,
4343
DateTimeInterface::ISO8601_EXPANDED,
44+
'U',
4445
];
4546

4647
/** @var list<non-empty-string> */
@@ -164,14 +165,14 @@ private static function filterTimezone(DateTimeZone|string|null $timeZone): Date
164165

165166
public function type(): FieldType
166167
{
167-
return FieldType::Date;
168+
return FieldType::Datetime;
168169
}
169170

170171
public function name(): string
171172
{
172173
$format = ('U' === $this->format) ? 'timestamp' : $this->format;
173174

174-
return FieldType::Date->value.'(format='.$format.',timezone='.$this->timezone->getName().')';
175+
return FieldType::Datetime->value.'(format='.$format.',timezone='.$this->timezone->getName().')';
175176
}
176177

177178
public function parse(mixed $value): ?DateTimeInterface
Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@
1919
use PHPUnit\Framework\Attributes\CoversClass;
2020
use PHPUnit\Framework\TestCase;
2121

22-
#[CoversClass(DateField::class)]
23-
final class DateFieldTest extends TestCase
22+
#[CoversClass(DateTimeField::class)]
23+
final class DateTimeFieldTest extends TestCase
2424
{
25-
private DateField $field;
25+
private DateTimeField $field;
2626

2727
protected function setUp(): void
2828
{
29-
$this->field = new DateField('Y-m-d');
29+
$this->field = new DateTimeField('Y-m-d');
3030
}
3131

3232
public function testParseUsesNativeConstructorWhenFormatIsEmpty(): void
@@ -39,7 +39,7 @@ public function testParseUsesNativeConstructorWhenFormatIsEmpty(): void
3939

4040
public function testParseUsesCreateFromFormatWhenFormatIsProvided(): void
4141
{
42-
$field = new DateField('d-m-Y');
42+
$field = new DateTimeField('d-m-Y');
4343
$result = $field->parse('01-01-2024');
4444

4545
self::assertInstanceOf(DateTimeImmutable::class, $result);
@@ -67,20 +67,20 @@ public function testItReturnsNullForInvalidValues(): void
6767

6868
public function test_it_can_return_another_implementing_datetime_interface(): void
6969
{
70-
$field = new DateField('Y-m-d', outputClass: MyDate::class);
70+
$field = new DateTimeField('Y-m-d', outputClass: MyDate::class);
7171
$result = $field->parse('2024-01-01');
7272

7373
self::assertInstanceOf(MyDate::class, $result);
7474
self::assertSame('2024-01-01', $result->format('Y-m-d'));
7575
self::assertSame(MyDate::class, $field->metadata()->get('class'));
7676
self::assertSame('Y-m-d', $field->metadata()->get('format'));
7777
self::assertSame('UTC', $field->metadata()->get('timezone'));
78-
self::assertSame('date(format=Y-m-d,timezone=UTC)', $field->name());
78+
self::assertSame('datetime(format=Y-m-d,timezone=UTC)', $field->name());
7979
}
8080

8181
public function test_it_uses_a_simpler_representation_for_timestamp(): void
8282
{
83-
self::assertSame('date(format=timestamp,timezone=UTC)', DateField::timestamp()->name());
83+
self::assertSame('datetime(format=timestamp,timezone=UTC)', DateTimeField::timestamp()->name());
8484
}
8585
}
8686

0 commit comments

Comments
 (0)