Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"vimeo/psalm": "5.15.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>> */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't something like Field\*::NAME type possible to narrow it a bit more?

private array $fields = [];

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

public static function ofLocalDate(LocalDate $localDate): self
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIR @BenMorel prefers to avoid self type as he considers it less readable than the original type itself

{
$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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not prevent adding an empty $value here? is there any reason to allow it when it seems they are filtered out below?

}

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does && $this->fields[$name]; check for? a non-empty-string?
maybe better to be explicit for readability? && '' !== $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));
}
Comment on lines +117 to +119
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't seem to be tested.

Also, can't hasField be used here?


return $value;
}

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here? '' !== $this->fields[$name] ?

return array_shift($this->fields[$name]);
}
}

return '';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return null here instead? empty string feels a bit odd, unless i'm missing something

}

/**
* @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;
}
179 changes: 179 additions & 0 deletions src/Formatter/IntlFormatter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?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;

/**
* 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')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a symfony polyfill for intl that works great, I believe it it should test the existence of a specific intl class instead of the extension to allow polyfill usage

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skeleton word isn't quite helpful to convey the meaning of what’s doing here IMO.

Could it be closer to what's used underneath? Like ofGeneratorPattern or something better?

{
$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.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing locale and pattern in message that throw this exception could be useful I guess

}

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.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use LocalTime::class to be future proof.

}

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm reading the code correctly, if a s Y pattern is passed, this line will consider the time part not supported when reaching the Y character? While it should be because there's the s before in the pattern right?

Shouldn't you initialize the supported types array as false and put a true value instead when parsing a character?

}

if (in_array($character, ['a', 'h', 'H', 'k', 'K', 'm', 's', 'S', 'A'], true)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick, I would put these arrays of characters in named constant to ease readability

$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