Skip to content

Commit dd306e2

Browse files
committed
add formatting API to LocalDate, LocalDateTime, LocalTime, and ZonedDateTime
1 parent 3cf5ecd commit dd306e2

13 files changed

Lines changed: 924 additions & 0 deletions

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"vimeo/psalm": "4.23.0"
2020
},
2121
"suggest": {
22+
"ext-intl": "This extension is required for locale-based formatting",
2223
"ext-timezonedb": "This PECL extension provides up-to-date timezone information"
2324
},
2425
"autoload": {
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Brick\DateTime\Formatter;
6+
7+
use Brick\DateTime\Field;
8+
use Brick\DateTime\LocalDate;
9+
use Brick\DateTime\LocalDateTime;
10+
use Brick\DateTime\LocalTime;
11+
use Brick\DateTime\ZonedDateTime;
12+
13+
use function abs;
14+
use function array_shift;
15+
use function floor;
16+
use function sprintf;
17+
18+
/**
19+
* An intermediate representation of a formatted date-time value.
20+
*/
21+
final class DateTimeFormatContext
22+
{
23+
/** @var LocalDate|LocalDateTime|LocalTime|ZonedDateTime */
24+
private $value;
25+
26+
/** @var array<string, list<string>> */
27+
private array $fields = [];
28+
29+
/**
30+
* @param LocalDate|LocalDateTime|LocalTime|ZonedDateTime $value
31+
*/
32+
private function __construct($value)
33+
{
34+
$this->value = $value;
35+
}
36+
37+
public static function ofLocalDate(LocalDate $localDate): self
38+
{
39+
$self = new self($localDate);
40+
$self->addField(Field\DayOfMonth::NAME, (string) $localDate->getDay());
41+
$self->addField(Field\DayOfWeek::NAME, (string) $localDate->getDayOfWeek()->getValue());
42+
$self->addField(Field\DayOfYear::NAME, (string) $localDate->getDayOfYear());
43+
$self->addField(Field\WeekOfYear::NAME, (string) $localDate->getYearWeek()->getWeek());
44+
$self->addField(Field\MonthOfYear::NAME, (string) $localDate->getMonth());
45+
$self->addField(Field\Year::NAME, (string) $localDate->getYear());
46+
47+
return $self;
48+
}
49+
50+
public static function ofLocalTime(LocalTime $localTime): self
51+
{
52+
$self = new self($localTime);
53+
$self->addField(Field\HourOfDay::NAME, (string) $localTime->getHour());
54+
$self->addField(Field\MinuteOfHour::NAME, (string) $localTime->getMinute());
55+
$self->addField(Field\SecondOfMinute::NAME, (string) $localTime->getSecond());
56+
$self->addField(Field\NanoOfSecond::NAME, (string) $localTime->getNano());
57+
$self->addField(Field\FractionOfSecond::NAME, (string) $localTime->getNano());
58+
59+
return $self;
60+
}
61+
62+
public static function ofLocalDateTime(LocalDateTime $localDateTime): self
63+
{
64+
$self = new self($localDateTime);
65+
$self->addField(Field\DayOfMonth::NAME, (string) $localDateTime->getDate()->getDay());
66+
$self->addField(Field\DayOfWeek::NAME, (string) $localDateTime->getDate()->getDayOfWeek()->getValue());
67+
$self->addField(Field\DayOfYear::NAME, (string) $localDateTime->getDate()->getDayOfYear());
68+
$self->addField(Field\WeekOfYear::NAME, (string) $localDateTime->getDate()->getYearWeek()->getWeek());
69+
$self->addField(Field\MonthOfYear::NAME, (string) $localDateTime->getDate()->getMonth());
70+
$self->addField(Field\Year::NAME, (string) $localDateTime->getDate()->getYear());
71+
$self->addField(Field\HourOfDay::NAME, (string) $localDateTime->getTime()->getHour());
72+
$self->addField(Field\MinuteOfHour::NAME, (string) $localDateTime->getTime()->getMinute());
73+
$self->addField(Field\SecondOfMinute::NAME, (string) $localDateTime->getTime()->getSecond());
74+
$self->addField(Field\NanoOfSecond::NAME, (string) $localDateTime->getTime()->getNano());
75+
$self->addField(Field\FractionOfSecond::NAME, (string) $localDateTime->getTime()->getNano());
76+
77+
return $self;
78+
}
79+
80+
public static function ofZonedDateTime(ZonedDateTime $zonedDateTime): self
81+
{
82+
$self = new self($zonedDateTime);
83+
$self->addField(Field\DayOfMonth::NAME, (string) $zonedDateTime->getDate()->getDay());
84+
$self->addField(Field\DayOfWeek::NAME, (string) $zonedDateTime->getDate()->getDayOfWeek()->getValue());
85+
$self->addField(Field\DayOfYear::NAME, (string) $zonedDateTime->getDate()->getDayOfYear());
86+
$self->addField(Field\WeekOfYear::NAME, (string) $zonedDateTime->getDate()->getYearWeek()->getWeek());
87+
$self->addField(Field\MonthOfYear::NAME, (string) $zonedDateTime->getDate()->getMonth());
88+
$self->addField(Field\Year::NAME, (string) $zonedDateTime->getDate()->getYear());
89+
$self->addField(Field\HourOfDay::NAME, (string) $zonedDateTime->getTime()->getHour());
90+
$self->addField(Field\MinuteOfHour::NAME, (string) $zonedDateTime->getTime()->getMinute());
91+
$self->addField(Field\SecondOfMinute::NAME, (string) $zonedDateTime->getTime()->getSecond());
92+
$self->addField(Field\NanoOfSecond::NAME, (string) $zonedDateTime->getTime()->getNano());
93+
$self->addField(Field\FractionOfSecond::NAME, (string) $zonedDateTime->getTime()->getNano());
94+
$self->addField(Field\TimeZoneOffsetHour::NAME, sprintf('%d', floor(abs($zonedDateTime->getTimeZoneOffset()->getTotalSeconds()) / LocalTime::SECONDS_PER_HOUR)));
95+
$self->addField(Field\TimeZoneOffsetMinute::NAME, (string) ((abs($zonedDateTime->getTimeZoneOffset()->getTotalSeconds()) % LocalTime::SECONDS_PER_HOUR) / LocalTime::SECONDS_PER_MINUTE));
96+
$self->addField(Field\TimeZoneOffsetSign::NAME, $zonedDateTime->getTimeZoneOffset()->getTotalSeconds() === 0 ? 'Z' : ($zonedDateTime->getTimeZoneOffset()->getTotalSeconds() > 0 ? '+' : '-'));
97+
$self->addField(Field\TimeZoneOffsetTotalSeconds::NAME, (string) $zonedDateTime->getTimeZoneOffset()->getTotalSeconds());
98+
$self->addField(Field\TimeZoneRegion::NAME, $zonedDateTime->getTimeZone()->getId());
99+
100+
return $self;
101+
}
102+
103+
public function addField(string $name, string $value): void
104+
{
105+
$this->fields[$name][] = $value;
106+
}
107+
108+
public function hasField(string $name): bool
109+
{
110+
return isset($this->fields[$name]) && $this->fields[$name];
111+
}
112+
113+
public function getField(string $name): string
114+
{
115+
$value = $this->getOptionalField($name);
116+
117+
if ($value === '') {
118+
throw new DateTimeFormatException(sprintf('Field %s is not present in the formatting context.', $name));
119+
}
120+
121+
return $value;
122+
}
123+
124+
public function getOptionalField(string $name): string
125+
{
126+
if (isset($this->fields[$name])) {
127+
if ($this->fields[$name]) {
128+
return array_shift($this->fields[$name]);
129+
}
130+
}
131+
132+
return '';
133+
}
134+
135+
/**
136+
* @return LocalDate|LocalDateTime|LocalTime|ZonedDateTime
137+
*/
138+
public function getValue()
139+
{
140+
return $this->value;
141+
}
142+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Brick\DateTime\Formatter;
6+
7+
use Brick\DateTime\DateTimeException;
8+
9+
/**
10+
* Exception thrown when a formatting error occurs.
11+
*/
12+
class DateTimeFormatException extends DateTimeException
13+
{
14+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Brick\DateTime\Formatter;
6+
7+
/**
8+
* Interface that all date-time formatters must implement.
9+
*/
10+
interface DateTimeFormatter
11+
{
12+
/**
13+
* @param DateTimeFormatContext $context Formatting context.
14+
*
15+
* @return string The formatted value.
16+
*
17+
* @throws DateTimeFormatException If the given context could not be formatted.
18+
*/
19+
public function format(DateTimeFormatContext $context): string;
20+
}

src/Formatter/IntlFormatter.php

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Brick\DateTime\Formatter;
6+
7+
use Brick\DateTime\LocalDate;
8+
use Brick\DateTime\LocalDateTime;
9+
use Brick\DateTime\LocalTime;
10+
use Brick\DateTime\ZonedDateTime;
11+
use DateTimeZone;
12+
use IntlDateFormatter;
13+
use IntlDatePatternGenerator;
14+
15+
use function array_keys;
16+
use function extension_loaded;
17+
use function get_class;
18+
use function in_array;
19+
use function sprintf;
20+
use function str_split;
21+
22+
use const PHP_VERSION_ID;
23+
24+
/**
25+
* Formats the value using the Intl extension.
26+
*/
27+
final class IntlFormatter implements DateTimeFormatter
28+
{
29+
public const FULL = IntlDateFormatter::FULL;
30+
public const LONG = IntlDateFormatter::LONG;
31+
public const MEDIUM = IntlDateFormatter::MEDIUM;
32+
public const SHORT = IntlDateFormatter::SHORT;
33+
34+
private string $locale;
35+
36+
private int $dateFormat;
37+
38+
private int $timeFormat;
39+
40+
private string $pattern;
41+
42+
private function __construct(string $locale, int $dateFormat, int $timeFormat, string $pattern)
43+
{
44+
$this->locale = $locale;
45+
$this->dateFormat = $dateFormat;
46+
$this->timeFormat = $timeFormat;
47+
$this->pattern = $pattern;
48+
49+
if (! extension_loaded('intl')) {
50+
throw new DateTimeFormatException('IntlFormatter requires ext-intl to be installed and enabled.');
51+
}
52+
}
53+
54+
/**
55+
* Returns a formatter of given type for a date value.
56+
*/
57+
public static function ofDate(string $locale, int $format): self
58+
{
59+
return new self($locale, $format, IntlDateFormatter::NONE, '');
60+
}
61+
62+
/**
63+
* Returns a formatter of given type for a time value.
64+
*/
65+
public static function ofTime(string $locale, int $format): self
66+
{
67+
return new self($locale, IntlDateFormatter::NONE, $format, '');
68+
}
69+
70+
/**
71+
* Returns a formatter of given type for a date-time value.
72+
*/
73+
public static function ofDateTime(string $locale, int $dateFormat, int $timeFormat): self
74+
{
75+
return new self($locale, $dateFormat, $timeFormat, '');
76+
}
77+
78+
/**
79+
* Returns a formatter with given ICU SimpleFormat pattern.
80+
*/
81+
public static function ofPattern(string $locale, string $pattern): self
82+
{
83+
return new self($locale, IntlDateFormatter::NONE, IntlDateFormatter::NONE, $pattern);
84+
}
85+
86+
/**
87+
* Returns a formatter with a pattern that best matches given skeleton.
88+
*/
89+
public static function ofSkeleton(string $locale, string $skeleton): self
90+
{
91+
if (PHP_VERSION_ID < 80100) {
92+
throw new DateTimeFormatException('IntlFormatter::ofSkeleton() is only available in PHP 8.1 and above.');
93+
}
94+
95+
$generator = new IntlDatePatternGenerator($locale);
96+
$pattern = $generator->getBestPattern($skeleton);
97+
98+
if ($pattern === false) {
99+
throw new DateTimeFormatException('Failed to resolve the best formatting pattern for given locale and skeleton.');
100+
}
101+
102+
return self::ofPattern($locale, $pattern);
103+
}
104+
105+
public function format(DateTimeFormatContext $context): string
106+
{
107+
$value = $context->getValue();
108+
109+
if ($this->dateFormat !== IntlDateFormatter::NONE && $value instanceof LocalTime) {
110+
throw new DateTimeFormatException('IntlFormatter with a date part cannot be used to format Brick\DateTime\LocalTime.');
111+
}
112+
113+
if ($this->timeFormat !== IntlDateFormatter::NONE && $value instanceof LocalDate) {
114+
throw new DateTimeFormatException('IntlFormatter with a time part cannot be used to format Brick\DateTime\LocalDate.');
115+
}
116+
117+
if (($this->timeFormat === self::FULL || $this->timeFormat === self::LONG) && ! ($value instanceof ZonedDateTime)) {
118+
throw new DateTimeFormatException(sprintf('IntlFormatter with a long or full time part cannot be used to format %s.', get_class($value)));
119+
}
120+
121+
if ($this->pattern !== '') {
122+
self::checkPattern($this->pattern, $value);
123+
}
124+
125+
$timeZone = $value instanceof ZonedDateTime ? $value->getTimeZone()->toNativeDateTimeZone() : new DateTimeZone('UTC');
126+
$formatter = new IntlDateFormatter($this->locale, $this->dateFormat, $this->timeFormat, $timeZone, null, $this->pattern);
127+
128+
return $formatter->format($value->toNativeDateTimeImmutable());
129+
}
130+
131+
/**
132+
* @param LocalDate|LocalDateTime|LocalTime|ZonedDateTime $value
133+
*/
134+
private static function checkPattern(string $pattern, $value): void
135+
{
136+
$supportedTypesMap = [
137+
LocalDate::class => true,
138+
LocalDateTime::class => true,
139+
LocalTime::class => true,
140+
ZonedDateTime::class => true,
141+
];
142+
143+
$inString = false;
144+
foreach (str_split($pattern) as $character) {
145+
if ($character === '\'') {
146+
if ($inString) {
147+
$inString = false;
148+
149+
continue;
150+
}
151+
152+
$inString = true;
153+
154+
continue;
155+
}
156+
157+
if ($inString) {
158+
continue;
159+
}
160+
161+
if (in_array($character, ['G', 'y', 'Y', 'u', 'U', 'r', 'Q', 'q', 'M', 'L', 'w', 'W', 'd', 'D', 'F', 'g', 'E', 'e', 'c'], true)) {
162+
$supportedTypesMap[LocalTime::class] = false;
163+
}
164+
165+
if (in_array($character, ['a', 'h', 'H', 'k', 'K', 'm', 's', 'S', 'A'], true)) {
166+
$supportedTypesMap[LocalDate::class] = false;
167+
}
168+
169+
if (in_array($character, ['z', 'Z', 'O', 'v', 'V', 'X', 'x'], true)) {
170+
$supportedTypesMap[LocalDate::class] = false;
171+
$supportedTypesMap[LocalDateTime::class] = false;
172+
$supportedTypesMap[LocalTime::class] = false;
173+
}
174+
}
175+
176+
$supportedTypes = array_keys($supportedTypesMap, true, true);
177+
foreach ($supportedTypes as $supportedType) {
178+
if ($value instanceof $supportedType) {
179+
return;
180+
}
181+
}
182+
183+
throw new DateTimeFormatException(sprintf("IntlFormatter with pattern '%s' is incompatible with type %s.", $pattern, get_class($value)));
184+
}
185+
}

0 commit comments

Comments
 (0)