-
-
Notifications
You must be signed in to change notification settings - Fork 35
Add formatting API #61
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v0.6
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AFAIR @BenMorel prefers to avoid |
||
| { | ||
| $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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not prevent adding an empty |
||
| } | ||
|
|
||
| public function hasField(string $name): bool | ||
| { | ||
| return isset($this->fields[$name]) && $this->fields[$name]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what does |
||
| } | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doesn't seem to be tested. Also, can't |
||
|
|
||
| return $value; | ||
| } | ||
|
|
||
| public function getOptionalField(string $name): string | ||
| { | ||
| if (isset($this->fields[$name])) { | ||
| if ($this->fields[$name]) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same here? |
||
| return array_shift($this->fields[$name]); | ||
| } | ||
| } | ||
|
|
||
| return ''; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| } | ||
| 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 | ||
| { | ||
| } |
| 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; | ||
| } |
| 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')) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| { | ||
| $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.'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I'm reading the code correctly, if a 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)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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))); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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\*::NAMEtype possible to narrow it a bit more?