Skip to content

Commit

Permalink
feature #288 Add support for relative date-time (DateInterval) (sstok)
Browse files Browse the repository at this point in the history
This PR was merged into the 2.0-dev branch.

Discussion
----------

| Q             | A
| ------------- | ---
| Bug fix?      | yes/no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tickets       | 
| License       | MIT

This adds the `allow_relative` option to `DateTimeType` to allow the usage of datetime relative formatting (date interval).


Commits
-------

3aaa930 Add support for relative date-time (DateInterval)
  • Loading branch information
sstok authored Oct 11, 2020
2 parents 10ad9c3 + 3aaa930 commit e1093c8
Show file tree
Hide file tree
Showing 32 changed files with 1,020 additions and 62 deletions.
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
],
"require": {
"php": "^7.2",
"nesbot/carbon": "^2.38",
"psr/container": "^1.0.0",
"symfony/intl": "^4.4 || ^5.0",
"symfony/options-resolver": "^4.4 || ^5.0",
"symfony/property-access": "^4.4 || ^5.0"
"symfony/property-access": "^4.4 || ^5.0",
"symfony/string": "^5.1"
},
"replace": {
"rollerworks/search": "self.version",
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/types/datetime.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ The provided input can be provided localized.
The underlying data is stored as a ``DateTime`` object.

+----------------------+------------------------------------------------------------------------------+
| Output Data Type | ``DateTime`` |
| Output Data Type | can be ``DateTimeImmutable`` or ``Carbon\CarbonInterval`` |
+----------------------+------------------------------------------------------------------------------+
| Options | - `with_seconds`_ |
| | - `with_minutes`_ |
| | - `model_timezone`_ |
| | - `input_timezone`_ |
| | - `allow_relative`_ |
+----------------------+------------------------------------------------------------------------------+
| Parent type | :doc:`field </reference/types/field>` |
+----------------------+------------------------------------------------------------------------------+
Expand Down
22 changes: 22 additions & 0 deletions docs/reference/types/options/allow_relative.rst.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
allow_relative
~~~~~~~~~~~~~~

**type**: ``bool`` **default**: ``false``_

Enables the handling of relative Date and/or time formats like
``1 week``, ``6 years 3 hours``.

The actual datetime is relative to now (current date and time), use the minus (``-``)
sign like ``-1 year`` to invert interval to a past moment.

Internally this uses `Carbon DateInterval`_ to parse the (localized) format.

.. caution::

For Doctrine DBAL and ORM not all platforms are supported yet.
Currently only PostgreSQL and MySQL/MariaDB are supported.

Working se

.. _`Date/Time Format Syntax`: http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Time-Format-Syntax
.. _`Carbon DateInterval`: https://carbon.nesbot.com/docs/#api-interval
128 changes: 128 additions & 0 deletions lib/Core/Extension/Core/DataTransformer/DateIntervalTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

declare(strict_types=1);

/*
* This file is part of the RollerworksSearch package.
*
* (c) Sebastiaan Stok <[email protected]>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace Rollerworks\Component\Search\Extension\Core\DataTransformer;

use Carbon\CarbonInterval;
use Carbon\Translator;
use Rollerworks\Component\Search\DataTransformer;
use Rollerworks\Component\Search\Exception\TransformationFailedException;
use function Symfony\Component\String\u;

final class DateIntervalTransformer implements DataTransformer
{
/** @var string */
private $fromLocale;

/** @var string */
private $toLocale;

public function __construct(string $fromLocale, string $toLocale = null)
{
$this->fromLocale = $fromLocale;
$this->toLocale = $toLocale ?? $fromLocale;
}

/**
* @param CarbonInterval|null $value
*/
public function transform($value): string
{
if ($value === null) {
return '';
}

if (!$value instanceof CarbonInterval) {
throw new TransformationFailedException('Expected a CarbonInterval instance or null.');
}

$value = clone $value;
$value->locale($this->toLocale);

if ($value->invert === 1) {
return u($value->forHumans())->prepend('-')->toString();
}

return $value->forHumans();
}

/**
* @param string $value
*/
public function reverseTransform($value): ?CarbonInterval
{
if (!is_scalar($value)) {
throw new TransformationFailedException('Expected a scalar.');
}

if ($value === '') {
return null;
}

try {
$value = $this->translateNumberWords($value);
$uValue = u($value)->trim();

if ($uValue->startsWith('-')) {
return CarbonInterval::parseFromLocale($uValue->trimStart('-')->toString(), $this->fromLocale)->invert();
}

return CarbonInterval::parseFromLocale($uValue->toString(), $this->fromLocale);
} catch (\Exception $e) {
throw new TransformationFailedException('Unable to parse value to DateInterval', 0, $e);
}
}

private function translateNumberWords(string $timeString): string
{
$timeString = strtr($timeString, ['' => "'"]);

$translator = Translator::get($this->fromLocale);
$translations = $translator->getMessages();

if (!isset($translations[$this->fromLocale])) {
return $timeString;
}

$messages = $translations[$this->fromLocale];

foreach (['year', 'month', 'week', 'day', 'hour', 'minute', 'second'] as $item) {
foreach (explode('|', $messages[$item]) as $idx => $messagePart) {
if (preg_match('/[:%](count|time)/', $messagePart)) {
continue;
}

if ($messagePart[0] === '{') {
$idx = (int) substr($messagePart, 1, strpos($messagePart, '}'));
}

$messagePart = static::cleanWordFromTranslationString($messagePart);
$timeString = str_replace($messagePart, $idx.' '.$item, $timeString);
}
}

return $timeString;
}

/**
* Return the word cleaned from its translation codes.
*/
private static function cleanWordFromTranslationString(string $word): string
{
$word = str_replace([':count', '%count', ':time'], '', $word);
$word = strtr($word, ['' => "'"]);
$word = preg_replace('/({\d+(,(\d+|Inf))?}|[\[\]]\d+(,(\d+|Inf))?[\[\]])/', '', $word);

return trim($word);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

/*
* This file is part of the RollerworksSearch package.
*
* (c) Sebastiaan Stok <[email protected]>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace Rollerworks\Component\Search\Extension\Core\DataTransformer;

use Rollerworks\Component\Search\DataTransformer;
use Rollerworks\Component\Search\Exception\TransformationFailedException;
use Throwable;

/**
* Allows to use multiple transformers based on their type.
*
* This transformer is mostly used by DateTimeType to support
* both DateTime and DateInterval objects.
*
* @internal
*/
final class MultiTypeDataTransformer implements DataTransformer
{
/** @var array<class-string,DataTransformer> */
private $transformers;

/**
* @param array<class-string,DataTransformer> $transformers
*/
public function __construct(array $transformers)
{
$this->transformers = $transformers;
}

public function transform($value)
{
if ($value === null) {
return '';
}

$type = get_debug_type($value);

if (!isset($this->transformers[$type])) {
throw new TransformationFailedException(sprintf('Unsupported type "%s".', $type));
}

return $this->transformers[$type]->transform($value);
}

public function reverseTransform($value)
{
$finalException = null;

foreach ($this->transformers as $transformer) {
try {
return $transformer->reverseTransform($value);
} catch (Throwable $e) {
$finalException = new TransformationFailedException($e->getMessage().PHP_EOL.$e->getTraceAsString(), $e->getCode(), $finalException);

continue;
}
}

throw $finalException;
}
}
90 changes: 71 additions & 19 deletions lib/Core/Extension/Core/Type/DateTimeType.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@

namespace Rollerworks\Component\Search\Extension\Core\Type;

use Carbon\CarbonInterval;
use Rollerworks\Component\Search\Extension\Core\DataTransformer\DateIntervalTransformer;
use Rollerworks\Component\Search\Extension\Core\DataTransformer\DateTimeToLocalizedStringTransformer;
use Rollerworks\Component\Search\Extension\Core\DataTransformer\DateTimeToRfc3339Transformer;
use Rollerworks\Component\Search\Extension\Core\ValueComparator\DateTimeValueValueComparator;
use Rollerworks\Component\Search\Extension\Core\DataTransformer\MultiTypeDataTransformer;
use Rollerworks\Component\Search\Extension\Core\ValueComparator\DateTimeIntervalValueComparator;
use Rollerworks\Component\Search\Extension\Core\ValueComparator\DateTimeValueComparator;
use Rollerworks\Component\Search\Field\FieldConfig;
use Rollerworks\Component\Search\Field\SearchFieldView;
use Rollerworks\Component\Search\Value\Compare;
use Rollerworks\Component\Search\Value\Range;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
Expand All @@ -30,11 +35,16 @@ final class DateTimeType extends BaseDateTimeType
public const DEFAULT_DATE_FORMAT = \IntlDateFormatter::MEDIUM;
public const DEFAULT_TIME_FORMAT = \IntlDateFormatter::MEDIUM;

/** @var DateTimeValueComparator */
private $valueComparator;

/** @var DateTimeIntervalValueComparator */
private $valueComparatorInterval;

public function __construct()
{
$this->valueComparator = new DateTimeValueValueComparator();
$this->valueComparator = new DateTimeValueComparator();
$this->valueComparatorInterval = new DateTimeIntervalValueComparator();
}

public function buildType(FieldConfig $config, array $options): void
Expand All @@ -48,23 +58,55 @@ public function buildType(FieldConfig $config, array $options): void
$this->validateFormat('time_format', $options['time_format']);
}

$config->setViewTransformer(
new DateTimeToLocalizedStringTransformer(
$options['model_timezone'],
$options['view_timezone'],
$options['date_format'],
$options['time_format'],
\IntlDateFormatter::GREGORIAN,
$options['pattern']
)
);

$config->setNormTransformer(
new DateTimeToRfc3339Transformer(
$options['model_timezone'],
$options['view_timezone']
)
);
if ($options['allow_relative']) {
$config->setValueComparator($this->valueComparatorInterval);

$config->setViewTransformer(
new MultiTypeDataTransformer(
[
CarbonInterval::class => new DateIntervalTransformer(\Locale::getDefault()),
\DateTimeImmutable::class => new DateTimeToLocalizedStringTransformer(
$options['model_timezone'],
$options['view_timezone'],
$options['date_format'],
$options['time_format'],
\IntlDateFormatter::GREGORIAN,
$options['pattern']
),
]
)
);

$config->setNormTransformer(
new MultiTypeDataTransformer(
[
CarbonInterval::class => new DateIntervalTransformer('en'),
\DateTimeImmutable::class => new DateTimeToRfc3339Transformer(
$options['model_timezone'],
$options['view_timezone']
),
]
)
);
} else {
$config->setViewTransformer(
new DateTimeToLocalizedStringTransformer(
$options['model_timezone'],
$options['view_timezone'],
$options['date_format'],
$options['time_format'],
\IntlDateFormatter::GREGORIAN,
$options['pattern']
)
);

$config->setNormTransformer(
new DateTimeToRfc3339Transformer(
$options['model_timezone'],
$options['view_timezone']
)
);
}
}

public function buildView(SearchFieldView $view, FieldConfig $config, array $options): void
Expand All @@ -82,6 +124,7 @@ public function buildView(SearchFieldView $view, FieldConfig $config, array $opt
}

$view->vars['timezone'] = $options['view_timezone'] ?: date_default_timezone_get();
$view->vars['allow_relative'] = $options['allow_relative'];
$view->vars['pattern'] = $pattern;
}

Expand All @@ -92,12 +135,21 @@ public function configureOptions(OptionsResolver $resolver): void
'model_timezone' => 'UTC',
'view_timezone' => null,
'pattern' => null,
'allow_relative' => false,
'date_format' => self::DEFAULT_DATE_FORMAT,
'time_format' => self::DEFAULT_TIME_FORMAT,
'invalid_message' => static function (Options $options) {
if ($options['allow_relative']) {
return 'This value is not a valid datetime or date interval.';
}

return 'This value is not a valid datetime.';
},
]
);

$resolver->setAllowedTypes('pattern', ['string', 'null']);
$resolver->setAllowedTypes('allow_relative', 'bool');
$resolver->setAllowedTypes('date_format', ['int']);
$resolver->setAllowedTypes('time_format', ['int']);
}
Expand Down
Loading

0 comments on commit e1093c8

Please sign in to comment.