Skip to content

Commit

Permalink
add formatting API to LocalDate, LocalDateTime, LocalTime, and ZonedD…
Browse files Browse the repository at this point in the history
…ateTime
  • Loading branch information
jiripudil committed Jun 20, 2022
1 parent 3cf5ecd commit dd306e2
Show file tree
Hide file tree
Showing 13 changed files with 924 additions and 0 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"vimeo/psalm": "4.23.0"
},
"suggest": {
"ext-intl": "This extension is required for locale-based formatting",
"ext-timezonedb": "This PECL extension provides up-to-date timezone information"
},
"autoload": {
Expand Down
142 changes: 142 additions & 0 deletions src/Formatter/DateTimeFormatContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php

declare(strict_types=1);

namespace Brick\DateTime\Formatter;

use Brick\DateTime\Field;
use Brick\DateTime\LocalDate;
use Brick\DateTime\LocalDateTime;
use Brick\DateTime\LocalTime;
use Brick\DateTime\ZonedDateTime;

use function abs;
use function array_shift;
use function floor;
use function sprintf;

/**
* An intermediate representation of a formatted date-time value.
*/
final class DateTimeFormatContext
{
/** @var LocalDate|LocalDateTime|LocalTime|ZonedDateTime */
private $value;

/** @var array<string, list<string>> */
private array $fields = [];

/**
* @param LocalDate|LocalDateTime|LocalTime|ZonedDateTime $value
*/
private function __construct($value)
{
$this->value = $value;
}

public static function ofLocalDate(LocalDate $localDate): self
{
$self = new self($localDate);
$self->addField(Field\DayOfMonth::NAME, (string) $localDate->getDay());
$self->addField(Field\DayOfWeek::NAME, (string) $localDate->getDayOfWeek()->getValue());
$self->addField(Field\DayOfYear::NAME, (string) $localDate->getDayOfYear());
$self->addField(Field\WeekOfYear::NAME, (string) $localDate->getYearWeek()->getWeek());
$self->addField(Field\MonthOfYear::NAME, (string) $localDate->getMonth());
$self->addField(Field\Year::NAME, (string) $localDate->getYear());

return $self;
}

public static function ofLocalTime(LocalTime $localTime): self
{
$self = new self($localTime);
$self->addField(Field\HourOfDay::NAME, (string) $localTime->getHour());
$self->addField(Field\MinuteOfHour::NAME, (string) $localTime->getMinute());
$self->addField(Field\SecondOfMinute::NAME, (string) $localTime->getSecond());
$self->addField(Field\NanoOfSecond::NAME, (string) $localTime->getNano());
$self->addField(Field\FractionOfSecond::NAME, (string) $localTime->getNano());

return $self;
}

public static function ofLocalDateTime(LocalDateTime $localDateTime): self
{
$self = new self($localDateTime);
$self->addField(Field\DayOfMonth::NAME, (string) $localDateTime->getDate()->getDay());
$self->addField(Field\DayOfWeek::NAME, (string) $localDateTime->getDate()->getDayOfWeek()->getValue());
$self->addField(Field\DayOfYear::NAME, (string) $localDateTime->getDate()->getDayOfYear());
$self->addField(Field\WeekOfYear::NAME, (string) $localDateTime->getDate()->getYearWeek()->getWeek());
$self->addField(Field\MonthOfYear::NAME, (string) $localDateTime->getDate()->getMonth());
$self->addField(Field\Year::NAME, (string) $localDateTime->getDate()->getYear());
$self->addField(Field\HourOfDay::NAME, (string) $localDateTime->getTime()->getHour());
$self->addField(Field\MinuteOfHour::NAME, (string) $localDateTime->getTime()->getMinute());
$self->addField(Field\SecondOfMinute::NAME, (string) $localDateTime->getTime()->getSecond());
$self->addField(Field\NanoOfSecond::NAME, (string) $localDateTime->getTime()->getNano());
$self->addField(Field\FractionOfSecond::NAME, (string) $localDateTime->getTime()->getNano());

return $self;
}

public static function ofZonedDateTime(ZonedDateTime $zonedDateTime): self
{
$self = new self($zonedDateTime);
$self->addField(Field\DayOfMonth::NAME, (string) $zonedDateTime->getDate()->getDay());
$self->addField(Field\DayOfWeek::NAME, (string) $zonedDateTime->getDate()->getDayOfWeek()->getValue());
$self->addField(Field\DayOfYear::NAME, (string) $zonedDateTime->getDate()->getDayOfYear());
$self->addField(Field\WeekOfYear::NAME, (string) $zonedDateTime->getDate()->getYearWeek()->getWeek());
$self->addField(Field\MonthOfYear::NAME, (string) $zonedDateTime->getDate()->getMonth());
$self->addField(Field\Year::NAME, (string) $zonedDateTime->getDate()->getYear());
$self->addField(Field\HourOfDay::NAME, (string) $zonedDateTime->getTime()->getHour());
$self->addField(Field\MinuteOfHour::NAME, (string) $zonedDateTime->getTime()->getMinute());
$self->addField(Field\SecondOfMinute::NAME, (string) $zonedDateTime->getTime()->getSecond());
$self->addField(Field\NanoOfSecond::NAME, (string) $zonedDateTime->getTime()->getNano());
$self->addField(Field\FractionOfSecond::NAME, (string) $zonedDateTime->getTime()->getNano());
$self->addField(Field\TimeZoneOffsetHour::NAME, sprintf('%d', floor(abs($zonedDateTime->getTimeZoneOffset()->getTotalSeconds()) / LocalTime::SECONDS_PER_HOUR)));
$self->addField(Field\TimeZoneOffsetMinute::NAME, (string) ((abs($zonedDateTime->getTimeZoneOffset()->getTotalSeconds()) % LocalTime::SECONDS_PER_HOUR) / LocalTime::SECONDS_PER_MINUTE));
$self->addField(Field\TimeZoneOffsetSign::NAME, $zonedDateTime->getTimeZoneOffset()->getTotalSeconds() === 0 ? 'Z' : ($zonedDateTime->getTimeZoneOffset()->getTotalSeconds() > 0 ? '+' : '-'));
$self->addField(Field\TimeZoneOffsetTotalSeconds::NAME, (string) $zonedDateTime->getTimeZoneOffset()->getTotalSeconds());
$self->addField(Field\TimeZoneRegion::NAME, $zonedDateTime->getTimeZone()->getId());

return $self;
}

public function addField(string $name, string $value): void
{
$this->fields[$name][] = $value;
}

public function hasField(string $name): bool
{
return isset($this->fields[$name]) && $this->fields[$name];
}

public function getField(string $name): string
{
$value = $this->getOptionalField($name);

if ($value === '') {
throw new DateTimeFormatException(sprintf('Field %s is not present in the formatting context.', $name));
}

return $value;
}

public function getOptionalField(string $name): string
{
if (isset($this->fields[$name])) {
if ($this->fields[$name]) {
return array_shift($this->fields[$name]);
}
}

return '';
}

/**
* @return LocalDate|LocalDateTime|LocalTime|ZonedDateTime
*/
public function getValue()
{
return $this->value;
}
}
14 changes: 14 additions & 0 deletions src/Formatter/DateTimeFormatException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Brick\DateTime\Formatter;

use Brick\DateTime\DateTimeException;

/**
* Exception thrown when a formatting error occurs.
*/
class DateTimeFormatException extends DateTimeException
{
}
20 changes: 20 additions & 0 deletions src/Formatter/DateTimeFormatter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Brick\DateTime\Formatter;

/**
* Interface that all date-time formatters must implement.
*/
interface DateTimeFormatter
{
/**
* @param DateTimeFormatContext $context Formatting context.
*
* @return string The formatted value.
*
* @throws DateTimeFormatException If the given context could not be formatted.
*/
public function format(DateTimeFormatContext $context): string;
}
185 changes: 185 additions & 0 deletions src/Formatter/IntlFormatter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<?php

declare(strict_types=1);

namespace Brick\DateTime\Formatter;

use Brick\DateTime\LocalDate;
use Brick\DateTime\LocalDateTime;
use Brick\DateTime\LocalTime;
use Brick\DateTime\ZonedDateTime;
use DateTimeZone;
use IntlDateFormatter;
use IntlDatePatternGenerator;

use function array_keys;
use function extension_loaded;
use function get_class;
use function in_array;
use function sprintf;
use function str_split;

use const PHP_VERSION_ID;

/**
* Formats the value using the Intl extension.
*/
final class IntlFormatter implements DateTimeFormatter
{
public const FULL = IntlDateFormatter::FULL;
public const LONG = IntlDateFormatter::LONG;
public const MEDIUM = IntlDateFormatter::MEDIUM;
public const SHORT = IntlDateFormatter::SHORT;

private string $locale;

private int $dateFormat;

private int $timeFormat;

private string $pattern;

private function __construct(string $locale, int $dateFormat, int $timeFormat, string $pattern)
{
$this->locale = $locale;
$this->dateFormat = $dateFormat;
$this->timeFormat = $timeFormat;
$this->pattern = $pattern;

if (! extension_loaded('intl')) {
throw new DateTimeFormatException('IntlFormatter requires ext-intl to be installed and enabled.');
}
}

/**
* Returns a formatter of given type for a date value.
*/
public static function ofDate(string $locale, int $format): self
{
return new self($locale, $format, IntlDateFormatter::NONE, '');
}

/**
* Returns a formatter of given type for a time value.
*/
public static function ofTime(string $locale, int $format): self
{
return new self($locale, IntlDateFormatter::NONE, $format, '');
}

/**
* Returns a formatter of given type for a date-time value.
*/
public static function ofDateTime(string $locale, int $dateFormat, int $timeFormat): self
{
return new self($locale, $dateFormat, $timeFormat, '');
}

/**
* Returns a formatter with given ICU SimpleFormat pattern.
*/
public static function ofPattern(string $locale, string $pattern): self
{
return new self($locale, IntlDateFormatter::NONE, IntlDateFormatter::NONE, $pattern);
}

/**
* Returns a formatter with a pattern that best matches given skeleton.
*/
public static function ofSkeleton(string $locale, string $skeleton): self
{
if (PHP_VERSION_ID < 80100) {
throw new DateTimeFormatException('IntlFormatter::ofSkeleton() is only available in PHP 8.1 and above.');
}

$generator = new IntlDatePatternGenerator($locale);
$pattern = $generator->getBestPattern($skeleton);

if ($pattern === false) {
throw new DateTimeFormatException('Failed to resolve the best formatting pattern for given locale and skeleton.');
}

return self::ofPattern($locale, $pattern);
}

public function format(DateTimeFormatContext $context): string
{
$value = $context->getValue();

if ($this->dateFormat !== IntlDateFormatter::NONE && $value instanceof LocalTime) {
throw new DateTimeFormatException('IntlFormatter with a date part cannot be used to format Brick\DateTime\LocalTime.');
}

if ($this->timeFormat !== IntlDateFormatter::NONE && $value instanceof LocalDate) {
throw new DateTimeFormatException('IntlFormatter with a time part cannot be used to format Brick\DateTime\LocalDate.');
}

if (($this->timeFormat === self::FULL || $this->timeFormat === self::LONG) && ! ($value instanceof ZonedDateTime)) {
throw new DateTimeFormatException(sprintf('IntlFormatter with a long or full time part cannot be used to format %s.', get_class($value)));
}

if ($this->pattern !== '') {
self::checkPattern($this->pattern, $value);
}

$timeZone = $value instanceof ZonedDateTime ? $value->getTimeZone()->toNativeDateTimeZone() : new DateTimeZone('UTC');
$formatter = new IntlDateFormatter($this->locale, $this->dateFormat, $this->timeFormat, $timeZone, null, $this->pattern);

return $formatter->format($value->toNativeDateTimeImmutable());
}

/**
* @param LocalDate|LocalDateTime|LocalTime|ZonedDateTime $value
*/
private static function checkPattern(string $pattern, $value): void
{
$supportedTypesMap = [
LocalDate::class => true,
LocalDateTime::class => true,
LocalTime::class => true,
ZonedDateTime::class => true,
];

$inString = false;
foreach (str_split($pattern) as $character) {
if ($character === '\'') {
if ($inString) {
$inString = false;

continue;
}

$inString = true;

continue;
}

if ($inString) {
continue;
}

if (in_array($character, ['G', 'y', 'Y', 'u', 'U', 'r', 'Q', 'q', 'M', 'L', 'w', 'W', 'd', 'D', 'F', 'g', 'E', 'e', 'c'], true)) {
$supportedTypesMap[LocalTime::class] = false;
}

if (in_array($character, ['a', 'h', 'H', 'k', 'K', 'm', 's', 'S', 'A'], true)) {
$supportedTypesMap[LocalDate::class] = false;
}

if (in_array($character, ['z', 'Z', 'O', 'v', 'V', 'X', 'x'], true)) {
$supportedTypesMap[LocalDate::class] = false;
$supportedTypesMap[LocalDateTime::class] = false;
$supportedTypesMap[LocalTime::class] = false;
}
}

$supportedTypes = array_keys($supportedTypesMap, true, true);
foreach ($supportedTypes as $supportedType) {
if ($value instanceof $supportedType) {
return;
}
}

throw new DateTimeFormatException(sprintf("IntlFormatter with pattern '%s' is incompatible with type %s.", $pattern, get_class($value)));
}
}
Loading

0 comments on commit dd306e2

Please sign in to comment.