Skip to content

Commit

Permalink
feat!: introduce constructor for custom date formats
Browse files Browse the repository at this point in the history
A new constructor can be registered to declare which format(s) are
supported during the mapping of a date object. By default, any valid
timestamp or ATOM-formatted value will be accepted.

```php
(new \CuyZ\Valinor\MapperBuilder())
    // Both COOKIE and ATOM formats will be accepted
    ->registerConstructor(
        new \CuyZ\Valinor\Mapper\Object\DateTimeFormatConstructor(DATE_COOKIE, DATE_ATOM)
    )
    ->mapper()
    ->map(DateTimeInterface::class, 'Monday, 08-Nov-1971 13:37:42 UTC');
```

The previously very opinionated behaviour has been removed, but can be
temporarily used to help with the migration.

```php
(new \CuyZ\Valinor\MapperBuilder())
    ->registerConstructor(
        new \CuyZ\Valinor\Mapper\Object\BackwardCompatibilityDateTimeConstructor()
    )
    ->mapper()
    ->map(DateTimeInterface::class, 'Monday, 08-Nov-1971 13:37:42 UTC');
```
  • Loading branch information
romm committed Sep 1, 2022
1 parent bf7af18 commit f232cc0
Show file tree
Hide file tree
Showing 16 changed files with 528 additions and 275 deletions.
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,5 @@ nav:
- Handled types: mapping/handled-types.md
- Other:
- Performance & caching: other/performance-and-cache.md
- Dealing with dates: other/dealing-with-dates.md
- Static analysis: other/static-analysis.md
52 changes: 52 additions & 0 deletions docs/pages/other/dealing-with-dates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Dealing with dates

When the mapper builds a date object, it has to know which format(s) are
supported. By default, any valid timestamp or ATOM-formatted value will be
accepted.

If other formats are to be supported, they need to be registered using the
following constructor:

```php
(new \CuyZ\Valinor\MapperBuilder())
// Both `Cookie` and `ATOM` formats will be accepted
->registerConstructor(
new \CuyZ\Valinor\Mapper\Object\DateTimeFormatConstructor(DATE_COOKIE, DATE_ATOM)
)
->mapper()
->map(DateTimeInterface::class, 'Monday, 08-Nov-1971 13:37:42 UTC');
```

## Custom date class implementation

By default, the library will map a `DateTimeInterface` to a `DateTimeImmutable`
instance. If other implementations are to be supported, custom constructors can
be used.

Here is an implementation example for the [nesbot/carbon] library:

```php
(new MapperBuilder())
// When the mapper meets a `DateTimeInterface` it will convert it to Carbon
->infer(DateTimeInterface::class, fn () => \Carbon\Carbon::class)

// We teach the mapper how to create a Carbon instance
->registerConstructor(function (string $time): \Carbon\Carbon {
// Only `Cookie` format will be accepted
return Carbon::createFromFormat(DATE_COOKIE, $time);
})

// Carbon uses its own exceptions, so we need to wrap it for the mapper
->filterExceptions(function (Throwable $exception) {
if ($exception instanceof \Carbon\Exceptions\Exception) {
return \CuyZ\Valinor\Mapper\Tree\Message\ThrowableMessage::from($exception);
}

throw $exception;
})

->mapper()
->map(DateTimeInterface::class, 'Monday, 08-Nov-1971 13:37:42 UTC');
```

[nesbot/carbon]: https://github.com/briannesbitt/Carbon
2 changes: 1 addition & 1 deletion src/Library/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ public function __construct(Settings $settings)

$factory = new ReflectionObjectBuilderFactory();
$factory = new ConstructorObjectBuilderFactory($factory, $settings->nativeConstructors, $constructors);
$factory = new DateTimeObjectBuilderFactory($factory, $constructors);
$factory = new DateTimeObjectBuilderFactory($factory, $this->get(FunctionDefinitionRepository::class));
$factory = new AttributeObjectBuilderFactory($factory);
$factory = new CollisionObjectBuilderFactory($factory);

Expand Down
95 changes: 95 additions & 0 deletions src/Mapper/Object/BackwardCompatibilityDateTimeConstructor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Mapper\Object;

use CuyZ\Valinor\Mapper\Object\Exception\CannotParseToBackwardCompatibilityDateTime;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;

/**
* @deprecated This class is only here to support the old datetime mapping
* behaviour. It can be used to temporarily have the same behaviour
* during the mapping. A migration to {@see DateTimeFormatConstructor} is
* strongly recommended.
*
* Usage:
*
* ```php
* (new \CuyZ\Valinor\MapperBuilder())
* ->registerConstructor(new BackwardCompatibilityDateTimeConstructor())
* ->mapper()
* ->map(SomeClass::class, […]);
* ```
*
* @api
*/
final class BackwardCompatibilityDateTimeConstructor
{
public const DATE_MYSQL = 'Y-m-d H:i:s';
public const DATE_PGSQL = 'Y-m-d H:i:s.u';
public const DATE_WITHOUT_TIME = '!Y-m-d';

/**
* @param class-string<DateTime|DateTimeImmutable> $className
* @param non-empty-string|positive-int|array{datetime: non-empty-string|positive-int, format?: ?non-empty-string} $value
*/
#[DynamicConstructor]
public function __invoke(string $className, $value): DateTimeInterface
{
$datetime = $value;
$format = null;

if (is_array($datetime)) {
$format = $datetime['format'] ?? null;
$datetime = $datetime['datetime'];
}

if ($format) {
$date = $this->tryFormat($className, (string)$datetime, $format);
} elseif (is_int($datetime)) {
$date = $this->tryFormat($className, (string)$datetime, 'U');
} else {
$date = $this->tryAllFormats($className, $datetime);
}

if (! $date) {
// @PHP8.0 use throw exception expression
throw new CannotParseToBackwardCompatibilityDateTime($datetime);
}

return $date;
}

/**
* @param class-string<DateTime|DateTimeImmutable> $className
*/
private function tryAllFormats(string $className, string $value): ?DateTimeInterface
{
$formats = [
self::DATE_MYSQL, self::DATE_PGSQL, DATE_ATOM, DATE_RFC850, DATE_COOKIE,
DATE_RFC822, DATE_RFC1036, DATE_RFC1123, DATE_RFC2822, DATE_RFC3339,
DATE_RFC3339_EXTENDED, DATE_RFC7231, DATE_RSS, DATE_W3C, self::DATE_WITHOUT_TIME,
];

foreach ($formats as $format) {
$date = $this->tryFormat($className, $value, $format);

if ($date instanceof DateTimeInterface) {
return $date;
}
}

return null;
}

/**
* @param class-string<DateTime|DateTimeImmutable> $className
*/
private function tryFormat(string $className, string $value, string $format): ?DateTimeInterface
{
return $className::createFromFormat($format, $value) ?: null;
}
}
63 changes: 63 additions & 0 deletions src/Mapper/Object/DateTimeFormatConstructor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Mapper\Object;

use CuyZ\Valinor\Mapper\Object\Exception\CannotParseToDateTime;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;

/**
* Can be given to {@see MapperBuilder::registerConstructor()} to describe which
* date formats should be allowed during mapping.
*
* By default, if this constructor is never registered, the dates will accept
* any valid timestamp or ATOM-formatted value.
*
* Usage:
*
* ```php
* (new \CuyZ\Valinor\MapperBuilder())
* // Both `Cookie` and `ATOM` formats will be accepted
* ->registerConstructor(new DateTimeFormatConstructor(DATE_COOKIE, DATE_ATOM))
* ->mapper()
* ->map(DateTimeInterface::class, 'Monday, 08-Nov-1971 13:37:42 UTC');
* ```
*
* @api
*/
final class DateTimeFormatConstructor
{
/** @var non-empty-array<non-empty-string> */
private array $formats;

/**
* @param non-empty-string $format
* @param non-empty-string ...$formats
*/
public function __construct(string $format, string ...$formats)
{
$this->formats = [$format, ...$formats];
}

/**
* @param class-string<DateTime|DateTimeImmutable> $className
* @param non-empty-string|positive-int $value
* @PHP8.0 union
*/
#[DynamicConstructor]
public function __invoke(string $className, $value): DateTimeInterface
{
foreach ($this->formats as $format) {
$date = $className::createFromFormat($format, (string)$value) ?: null;

if ($date) {
return $date;
}
}

throw new CannotParseToDateTime($value, $this->formats);
}
}
100 changes: 0 additions & 100 deletions src/Mapper/Object/DateTimeObjectBuilder.php

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Mapper\Object\Exception;

use CuyZ\Valinor\Mapper\Tree\Message\ErrorMessage;
use CuyZ\Valinor\Mapper\Tree\Message\HasParameters;
use CuyZ\Valinor\Utility\String\StringFormatter;
use CuyZ\Valinor\Utility\ValueDumper;
use RuntimeException;

/** @internal */
final class CannotParseToBackwardCompatibilityDateTime extends RuntimeException implements ErrorMessage, HasParameters
{
private string $body = 'Value {value} does not match a valid date format.';

/** @var array<string, string> */
private array $parameters;

/**
* @param string|int $datetime
*/
public function __construct($datetime)
{
$this->parameters = [
'value' => ValueDumper::dump($datetime),
];

parent::__construct(StringFormatter::for($this), 1659706547);
}

public function body(): string
{
return $this->body;
}

public function parameters(): array
{
return $this->parameters;
}
}
6 changes: 4 additions & 2 deletions src/Mapper/Object/Exception/CannotParseToDateTime.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,20 @@
/** @internal */
final class CannotParseToDateTime extends RuntimeException implements ErrorMessage, HasParameters
{
private string $body = 'Value {value} does not match a valid date format.';
private string $body = 'Value {value} does not match any of the following formats: {formats}.';

/** @var array<string, string> */
private array $parameters;

/**
* @param string|int $datetime
* @param non-empty-list<non-empty-string> $formats
*/
public function __construct($datetime)
public function __construct($datetime, array $formats)
{
$this->parameters = [
'value' => ValueDumper::dump($datetime),
'formats' => '`' . implode('`, `', $formats) . '`'
];

parent::__construct(StringFormatter::for($this), 1630686564);
Expand Down
Loading

0 comments on commit f232cc0

Please sign in to comment.