diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index fbbbcc84..2dece108 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -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 diff --git a/docs/pages/other/dealing-with-dates.md b/docs/pages/other/dealing-with-dates.md new file mode 100644 index 00000000..c3a9f1cd --- /dev/null +++ b/docs/pages/other/dealing-with-dates.md @@ -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 diff --git a/src/Library/Container.php b/src/Library/Container.php index daa3aba0..7753607b 100644 --- a/src/Library/Container.php +++ b/src/Library/Container.php @@ -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); diff --git a/src/Mapper/Object/BackwardCompatibilityDateTimeConstructor.php b/src/Mapper/Object/BackwardCompatibilityDateTimeConstructor.php new file mode 100644 index 00000000..0ebd85d7 --- /dev/null +++ b/src/Mapper/Object/BackwardCompatibilityDateTimeConstructor.php @@ -0,0 +1,95 @@ +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 $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 $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 $className + */ + private function tryFormat(string $className, string $value, string $format): ?DateTimeInterface + { + return $className::createFromFormat($format, $value) ?: null; + } +} diff --git a/src/Mapper/Object/DateTimeFormatConstructor.php b/src/Mapper/Object/DateTimeFormatConstructor.php new file mode 100644 index 00000000..3d9d1e34 --- /dev/null +++ b/src/Mapper/Object/DateTimeFormatConstructor.php @@ -0,0 +1,63 @@ +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 */ + 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 $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); + } +} diff --git a/src/Mapper/Object/DateTimeObjectBuilder.php b/src/Mapper/Object/DateTimeObjectBuilder.php deleted file mode 100644 index 747ddf67..00000000 --- a/src/Mapper/Object/DateTimeObjectBuilder.php +++ /dev/null @@ -1,100 +0,0 @@ - */ - private string $className; - - private Arguments $arguments; - - /** - * @param class-string $className - */ - public function __construct(string $className) - { - $this->className = $className; - } - - public function describeArguments(): Arguments - { - return $this->arguments ??= new Arguments(Argument::forDateTime()); - } - - public function build(array $arguments): DateTimeInterface - { - $datetime = $arguments['value']; - $format = null; - - if (is_array($datetime)) { - $format = $datetime['format'] ?? null; - $datetime = $datetime['datetime']; - } - - assert(is_string($datetime) || is_int($datetime)); - assert(is_string($format) || is_null($format)); - - if ($format) { - $date = $this->tryFormat((string)$datetime, $format); - } elseif (is_int($datetime)) { - $date = $this->tryFormat((string)$datetime, 'U'); - } else { - $date = $this->tryAllFormats($datetime); - } - - if (! $date) { - // @PHP8.0 use throw exception expression - throw new CannotParseToDateTime($datetime); - } - - return $date; - } - - public function signature(): string - { - return 'Internal date object builder'; - } - - private function tryAllFormats(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($value, $format); - - if ($date instanceof DateTimeInterface) { - return $date; - } - } - - return null; - } - - private function tryFormat(string $value, string $format): ?DateTimeInterface - { - return ($this->className)::createFromFormat($format, $value) ?: null; - } -} diff --git a/src/Mapper/Object/Exception/CannotParseToBackwardCompatibilityDateTime.php b/src/Mapper/Object/Exception/CannotParseToBackwardCompatibilityDateTime.php new file mode 100644 index 00000000..33299fe5 --- /dev/null +++ b/src/Mapper/Object/Exception/CannotParseToBackwardCompatibilityDateTime.php @@ -0,0 +1,42 @@ + */ + 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; + } +} diff --git a/src/Mapper/Object/Exception/CannotParseToDateTime.php b/src/Mapper/Object/Exception/CannotParseToDateTime.php index 102546a8..34bc52a0 100644 --- a/src/Mapper/Object/Exception/CannotParseToDateTime.php +++ b/src/Mapper/Object/Exception/CannotParseToDateTime.php @@ -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 */ private array $parameters; /** * @param string|int $datetime + * @param non-empty-list $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); diff --git a/src/Mapper/Object/Factory/ConstructorObjectBuilderFactory.php b/src/Mapper/Object/Factory/ConstructorObjectBuilderFactory.php index ddfa3355..8a8917f4 100644 --- a/src/Mapper/Object/Factory/ConstructorObjectBuilderFactory.php +++ b/src/Mapper/Object/Factory/ConstructorObjectBuilderFactory.php @@ -7,6 +7,8 @@ use CuyZ\Valinor\Definition\ClassDefinition; use CuyZ\Valinor\Definition\FunctionObject; use CuyZ\Valinor\Definition\FunctionsContainer; +use CuyZ\Valinor\Mapper\Object\BackwardCompatibilityDateTimeConstructor; +use CuyZ\Valinor\Mapper\Object\DateTimeFormatConstructor; use CuyZ\Valinor\Mapper\Object\DynamicConstructor; use CuyZ\Valinor\Mapper\Object\Exception\CannotInstantiateObject; use CuyZ\Valinor\Mapper\Object\Exception\InvalidConstructorClassTypeParameter; @@ -113,7 +115,11 @@ private function constructorMatches(FunctionObject $function, ClassType $classTy return false; } - if (! $definition->attributes()->has(DynamicConstructor::class)) { + if (! $definition->attributes()->has(DynamicConstructor::class) + // @PHP8.0 remove + && $definition->class() !== DateTimeFormatConstructor::class + && $definition->class() !== BackwardCompatibilityDateTimeConstructor::class + ) { return true; } diff --git a/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php b/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php index 4e5f57ae..a50c291b 100644 --- a/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php +++ b/src/Mapper/Object/Factory/DateTimeObjectBuilderFactory.php @@ -5,76 +5,68 @@ namespace CuyZ\Valinor\Mapper\Object\Factory; use CuyZ\Valinor\Definition\ClassDefinition; -use CuyZ\Valinor\Definition\FunctionsContainer; -use CuyZ\Valinor\Mapper\Object\DateTimeObjectBuilder; +use CuyZ\Valinor\Definition\FunctionObject; +use CuyZ\Valinor\Definition\Repository\FunctionDefinitionRepository; +use CuyZ\Valinor\Mapper\Object\DateTimeFormatConstructor; use CuyZ\Valinor\Mapper\Object\FunctionObjectBuilder; +use CuyZ\Valinor\Mapper\Object\NativeConstructorObjectBuilder; use CuyZ\Valinor\Mapper\Object\ObjectBuilder; use CuyZ\Valinor\Type\Types\ClassType; -use DateTimeInterface; +use DateTime; +use DateTimeImmutable; +use function array_filter; use function count; -use function is_a; /** @internal */ final class DateTimeObjectBuilderFactory implements ObjectBuilderFactory { private ObjectBuilderFactory $delegate; - private FunctionsContainer $functions; + private FunctionDefinitionRepository $functionDefinitionRepository; - /** @var array */ - private array $builders = []; - - public function __construct(ObjectBuilderFactory $delegate, FunctionsContainer $functions) + public function __construct(ObjectBuilderFactory $delegate, FunctionDefinitionRepository $functionDefinitionRepository) { $this->delegate = $delegate; - $this->functions = $functions; + $this->functionDefinitionRepository = $functionDefinitionRepository; } public function for(ClassDefinition $class): array { $className = $class->name(); - if (! is_a($className, DateTimeInterface::class, true)) { - return $this->delegate->for($class); - } - - return $this->builders($class->type()); - } - - /** - * @return list - */ - private function builders(ClassType $type): array - { - /** @var class-string $className */ - $className = $type->className(); - $key = $type->toString(); - - if (! isset($this->builders[$key])) { - $overridesDefault = false; - - $this->builders[$key] = []; + $builders = $this->delegate->for($class); - foreach ($this->functions as $function) { - $definition = $function->definition(); + if ($className !== DateTime::class && $className !== DateTimeImmutable::class) { + return $builders; + } - if (! $definition->returnType()->matches($type)) { - continue; - } + // Remove `DateTime` & `DateTimeImmutable` native constructors + $builders = array_filter($builders, fn (ObjectBuilder $builder) => ! $builder instanceof NativeConstructorObjectBuilder); - if (count($definition->parameters()) === 1) { - $overridesDefault = true; - } + $useDefaultBuilder = true; - $this->builders[$key][] = new FunctionObjectBuilder($function, $type); + foreach ($builders as $builder) { + if (count($builder->describeArguments()) === 1) { + $useDefaultBuilder = false; + // @infection-ignore-all + break; } + } - if (! $overridesDefault) { - $this->builders[$key][] = new DateTimeObjectBuilder($className); - } + if ($useDefaultBuilder) { + // @infection-ignore-all / Ignore memoization + $builders[] = $this->defaultBuilder($class->type()); } - return $this->builders[$key]; + return $builders; + } + + private function defaultBuilder(ClassType $type): FunctionObjectBuilder + { + $constructor = new DateTimeFormatConstructor(DATE_ATOM, 'U'); + $function = new FunctionObject($this->functionDefinitionRepository->for($constructor), $constructor); + + return new FunctionObjectBuilder($function, $type); } } diff --git a/src/Mapper/Object/FunctionObjectBuilder.php b/src/Mapper/Object/FunctionObjectBuilder.php index 5d804bca..7373504e 100644 --- a/src/Mapper/Object/FunctionObjectBuilder.php +++ b/src/Mapper/Object/FunctionObjectBuilder.php @@ -33,7 +33,10 @@ public function __construct(FunctionObject $function, ClassType $type) array_values(iterator_to_array($definition->parameters())) // @PHP8.1 array unpacking ); - $this->isDynamicConstructor = $definition->attributes()->has(DynamicConstructor::class); + $this->isDynamicConstructor = $definition->attributes()->has(DynamicConstructor::class) + // @PHP8.0 remove + || $definition->class() === DateTimeFormatConstructor::class + || $definition->class() === BackwardCompatibilityDateTimeConstructor::class; if ($this->isDynamicConstructor) { array_shift($arguments); diff --git a/src/Mapper/Tree/Message/DefaultMessage.php b/src/Mapper/Tree/Message/DefaultMessage.php index edca621b..2ea6e9f6 100644 --- a/src/Mapper/Tree/Message/DefaultMessage.php +++ b/src/Mapper/Tree/Message/DefaultMessage.php @@ -62,6 +62,9 @@ interface DefaultMessage 'Value {value} does not match a valid date format.' => [ 'en' => 'Value {value} does not match a valid date format.', ], + 'Value {value} does not match any of the following formats: {formats}.' => [ + 'en' => 'Value {value} does not match any of the following formats: {formats}.', + ], 'Invalid class string {value}, it must be one of {expected_class_strings}.' => [ 'en' => 'Invalid class string {value}, it must be one of {expected_class_strings}.', ], diff --git a/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php index e0db0dd8..6071cf9c 100644 --- a/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php +++ b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php @@ -590,11 +590,32 @@ public function test_registered_datetime_constructor_is_used(): void self::assertSame($defaultImmutable, $resultImmutable); } + public function test_constructor_with_same_number_of_arguments_as_native_datetime_constructor_is_handled(): void + { + $default = new DateTime('@1659691266'); + $defaultImmutable = new DateTimeImmutable('@1659691266'); + + $mapper = (new MapperBuilder()) + ->registerConstructor(fn (int $timestamp, string $timezone): DateTime => $default) + ->registerConstructor(fn (int $timestamp, string $timezone): DateTimeImmutable => $defaultImmutable) + ->mapper(); + + try { + $result = $mapper->map(DateTime::class, ['timestamp' => 1659704697, 'timezone' => 'Europe/Paris']); + $resultImmutable = $mapper->map(DateTimeImmutable::class, ['timestamp' => 1659704697, 'timezone' => 'Europe/Paris']); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertSame($default, $result); + self::assertSame($defaultImmutable, $resultImmutable); + } + public function test_registered_datetime_constructor_not_matching_source_uses_default_constructor(): void { try { $result = (new MapperBuilder()) - ->registerConstructor(fn (string $foo, int $bar): DateTimeInterface => new DateTimeImmutable()) + ->registerConstructor(fn (string $foo, int $bar): DateTimeImmutable => new DateTimeImmutable()) ->mapper() ->map(DateTimeInterface::class, 1647781015); } catch (MappingError $error) { diff --git a/tests/Integration/Mapping/Object/BackwardCompatibilityDateTimeMappingTest.php b/tests/Integration/Mapping/Object/BackwardCompatibilityDateTimeMappingTest.php new file mode 100644 index 00000000..09d084f1 --- /dev/null +++ b/tests/Integration/Mapping/Object/BackwardCompatibilityDateTimeMappingTest.php @@ -0,0 +1,153 @@ +mapper = (new MapperBuilder()) + ->registerConstructor(new BackwardCompatibilityDateTimeConstructor()) + ->mapper(); + } + + public function test_datetime_properties_are_converted_properly(): void + { + $dateTimeInterface = new DateTimeImmutable('@' . $this->buildRandomTimestamp()); + $dateTimeImmutable = new DateTimeImmutable('@' . $this->buildRandomTimestamp()); + $dateTimeFromTimestamp = $this->buildRandomTimestamp(); + $dateTimeFromTimestampWithOutFormat = [ + 'datetime' => $this->buildRandomTimestamp(), + ]; + $dateTimeFromTimestampWithFormat = [ + 'datetime' => $this->buildRandomTimestamp(), + 'format' => 'U', + ]; + $dateTimeFromAtomFormat = (new DateTime())->setTimestamp($this->buildRandomTimestamp())->format(DATE_ATOM); + $dateTimeFromArray = [ + 'datetime' => (new DateTime('@' . $this->buildRandomTimestamp()))->format('Y-m-d H:i:s'), + 'format' => 'Y-m-d H:i:s', + ]; + $mysqlDate = (new DateTime('@' . $this->buildRandomTimestamp()))->format('Y-m-d H:i:s'); + $pgsqlDate = (new DateTime('@' . $this->buildRandomTimestamp()))->format('Y-m-d H:i:s.u'); + + $sqlDateNotTime = '2022-04-30'; + + try { + $result = $this->mapper->map(AllDateTimeValues::class, [ + 'dateTimeInterface' => $dateTimeInterface, + 'dateTimeImmutable' => $dateTimeImmutable, + 'dateTimeFromTimestamp' => $dateTimeFromTimestamp, + 'dateTimeFromTimestampWithOutFormat' => $dateTimeFromTimestampWithOutFormat, + 'dateTimeFromTimestampWithFormat' => $dateTimeFromTimestampWithFormat, + 'dateTimeFromAtomFormat' => $dateTimeFromAtomFormat, + 'dateTimeFromArray' => $dateTimeFromArray, + 'mysqlDate' => $mysqlDate, + 'pgsqlDate' => $pgsqlDate, + 'sqlDateNotTime' => $sqlDateNotTime, + + ]); + } catch (MappingError $error) { + $this->mappingFail($error); + } + + self::assertInstanceOf(DateTimeImmutable::class, $result->dateTimeInterface); + self::assertEquals($dateTimeInterface, $result->dateTimeInterface); + self::assertEquals($dateTimeImmutable, $result->dateTimeImmutable); + self::assertEquals(new DateTimeImmutable("@$dateTimeFromTimestamp"), $result->dateTimeFromTimestamp); + self::assertEquals(new DateTimeImmutable("@{$dateTimeFromTimestampWithFormat['datetime']}"), $result->dateTimeFromTimestampWithFormat); + self::assertEquals(new DateTimeImmutable("@{$dateTimeFromTimestampWithOutFormat['datetime']}"), $result->dateTimeFromTimestampWithOutFormat); + self::assertEquals(DateTimeImmutable::createFromFormat(DATE_ATOM, $dateTimeFromAtomFormat), $result->dateTimeFromAtomFormat); + self::assertEquals(DateTimeImmutable::createFromFormat($dateTimeFromArray['format'], $dateTimeFromArray['datetime']), $result->dateTimeFromArray); + self::assertEquals(DateTimeImmutable::createFromFormat(BackwardCompatibilityDateTimeConstructor::DATE_MYSQL, $mysqlDate), $result->mysqlDate); + self::assertEquals(DateTimeImmutable::createFromFormat(BackwardCompatibilityDateTimeConstructor::DATE_PGSQL, $pgsqlDate), $result->pgsqlDate); + self::assertSame($sqlDateNotTime . ' 00:00:00', $result->sqlDateNotTime->format(BackwardCompatibilityDateTimeConstructor::DATE_MYSQL)); + } + + public function test_invalid_datetime_throws_exception(): void + { + try { + $this->mapper->map(DateTimeInterface::class, 'invalid datetime'); + } catch (MappingError $exception) { + $error = $exception->node()->messages()[0]; + + self::assertSame('1659706547', $error->code()); + self::assertSame("Value 'invalid datetime' does not match a valid date format.", (string)$error); + } + } + + public function test_invalid_datetime_from_array_throws_exception(): void + { + try { + $this->mapper->map(DateTimeInterface::class, [ + 'datetime' => 1337, + 'format' => 'H', + ]); + } catch (MappingError $exception) { + $error = $exception->node()->messages()[0]; + + self::assertSame('1659706547', $error->code()); + self::assertSame("Value 1337 does not match a valid date format.", (string)$error); + } + } + + public function test_invalid_array_source_throws_exception(): void + { + try { + $this->mapper->map(DateTimeInterface::class, [ + 'dateTime' => [ + 'invalid key' => '2012-12-21T13:37:42+00:00', + ], + ]); + } catch (MappingError $exception) { + $error = $exception->node()->children()['value']->messages()[0]; + + self::assertSame('1607027306', $error->code()); + } + } + + private function buildRandomTimestamp(): int + { + return random_int(1, 32503726800); + } +} + +final class AllDateTimeValues +{ + public DateTimeInterface $dateTimeInterface; + + public DateTimeImmutable $dateTimeImmutable; + + public DateTime $dateTimeFromTimestamp; + + public DateTime $dateTimeFromTimestampWithOutFormat; + + public DateTime $dateTimeFromTimestampWithFormat; + + public DateTimeInterface $dateTimeFromAtomFormat; + + public DateTimeInterface $dateTimeFromArray; + + public DateTimeInterface $mysqlDate; + + public DateTimeInterface $pgsqlDate; + + public DateTimeImmutable $sqlDateNotTime; +} diff --git a/tests/Integration/Mapping/Object/DateTimeMappingTest.php b/tests/Integration/Mapping/Object/DateTimeMappingTest.php index 5e90e35a..5206cbeb 100644 --- a/tests/Integration/Mapping/Object/DateTimeMappingTest.php +++ b/tests/Integration/Mapping/Object/DateTimeMappingTest.php @@ -5,143 +5,92 @@ namespace CuyZ\Valinor\Tests\Integration\Mapping\Object; use CuyZ\Valinor\Mapper\MappingError; -use CuyZ\Valinor\Mapper\Object\DateTimeObjectBuilder; +use CuyZ\Valinor\Mapper\Object\DateTimeFormatConstructor; use CuyZ\Valinor\MapperBuilder; use CuyZ\Valinor\Tests\Integration\IntegrationTest; -use DateTime; -use DateTimeImmutable; use DateTimeInterface; -use function random_int; - final class DateTimeMappingTest extends IntegrationTest { - public function test_datetime_properties_are_converted_properly(): void + public function test_default_datetime_constructor_cannot_be_used(): void { - $dateTimeInterface = new DateTimeImmutable('@' . $this->buildRandomTimestamp()); - $dateTimeImmutable = new DateTimeImmutable('@' . $this->buildRandomTimestamp()); - $dateTimeFromTimestamp = $this->buildRandomTimestamp(); - $dateTimeFromTimestampWithOutFormat = [ - 'datetime' => $this->buildRandomTimestamp(), - ]; - $dateTimeFromTimestampWithFormat = [ - 'datetime' => $this->buildRandomTimestamp(), - 'format' => 'U', - ]; - $dateTimeFromAtomFormat = (new DateTime())->setTimestamp($this->buildRandomTimestamp())->format(DATE_ATOM); - $dateTimeFromArray = [ - 'datetime' => (new DateTime('@' . $this->buildRandomTimestamp()))->format('Y-m-d H:i:s'), - 'format' => 'Y-m-d H:i:s', - ]; - $mysqlDate = (new DateTime('@' . $this->buildRandomTimestamp()))->format('Y-m-d H:i:s'); - $pgsqlDate = (new DateTime('@' . $this->buildRandomTimestamp()))->format('Y-m-d H:i:s.u'); + try { + (new MapperBuilder()) + ->mapper() + ->map(DateTimeInterface::class, ['datetime' => '2022/08/05', 'timezone' => 'Europe/Paris']); + } catch (MappingError $exception) { + $error = $exception->node()->children()['value']->messages()[0]; - $sqlDateNotTime = '2022-04-30'; + self::assertSame('1607027306', $error->code()); + } + } + public function test_default_date_constructor_with_valid_atom_format_source_returns_datetime(): void + { try { - $result = (new MapperBuilder())->mapper()->map(AllDateTimeValues::class, [ - 'dateTimeInterface' => $dateTimeInterface, - 'dateTimeImmutable' => $dateTimeImmutable, - 'dateTimeFromTimestamp' => $dateTimeFromTimestamp, - 'dateTimeFromTimestampWithOutFormat' => $dateTimeFromTimestampWithOutFormat, - 'dateTimeFromTimestampWithFormat' => $dateTimeFromTimestampWithFormat, - 'dateTimeFromAtomFormat' => $dateTimeFromAtomFormat, - 'dateTimeFromArray' => $dateTimeFromArray, - 'mysqlDate' => $mysqlDate, - 'pgsqlDate' => $pgsqlDate, - 'sqlDateNotTime' => $sqlDateNotTime, - - ]); + $result = (new MapperBuilder()) + ->mapper() + ->map(DateTimeInterface::class, '2022-08-05T08:32:06+00:00'); } catch (MappingError $error) { $this->mappingFail($error); } - self::assertInstanceOf(DateTimeImmutable::class, $result->dateTimeInterface); - self::assertEquals($dateTimeInterface, $result->dateTimeInterface); - self::assertEquals($dateTimeImmutable, $result->dateTimeImmutable); - self::assertEquals(new DateTimeImmutable("@$dateTimeFromTimestamp"), $result->dateTimeFromTimestamp); - self::assertEquals(new DateTimeImmutable("@{$dateTimeFromTimestampWithFormat['datetime']}"), $result->dateTimeFromTimestampWithFormat); - self::assertEquals(new DateTimeImmutable("@{$dateTimeFromTimestampWithOutFormat['datetime']}"), $result->dateTimeFromTimestampWithOutFormat); - self::assertEquals(DateTimeImmutable::createFromFormat(DATE_ATOM, $dateTimeFromAtomFormat), $result->dateTimeFromAtomFormat); - self::assertEquals(DateTimeImmutable::createFromFormat($dateTimeFromArray['format'], $dateTimeFromArray['datetime']), $result->dateTimeFromArray); - self::assertEquals(DateTimeImmutable::createFromFormat(DateTimeObjectBuilder::DATE_MYSQL, $mysqlDate), $result->mysqlDate); - self::assertEquals(DateTimeImmutable::createFromFormat(DateTimeObjectBuilder::DATE_PGSQL, $pgsqlDate), $result->pgsqlDate); - self::assertSame($sqlDateNotTime . ' 00:00:00', $result->sqlDateNotTime->format(DateTimeObjectBuilder::DATE_MYSQL)); + self::assertSame('2022-08-05T08:32:06+00:00', $result->format(DATE_ATOM)); } - public function test_invalid_datetime_throws_exception(): void + public function test_default_date_constructor_with_valid_timestamp_format_source_returns_datetime(): void { try { - (new MapperBuilder()) + $result = (new MapperBuilder()) ->mapper() - ->map(DateTimeInterface::class, 'invalid datetime'); - } catch (MappingError $exception) { - $error = $exception->node()->messages()[0]; + ->map(DateTimeInterface::class, 1659688380); + } catch (MappingError $error) { + $this->mappingFail($error); + } - self::assertSame('1630686564', $error->code()); - self::assertSame("Value 'invalid datetime' does not match a valid date format.", (string)$error); + self::assertSame('1659688380', $result->format('U')); + } + + public function test_registered_date_constructor_with_valid_source_returns_datetime(): void + { + try { + $result = (new MapperBuilder()) + ->registerConstructor(new DateTimeFormatConstructor('d/m/Y', 'Y/m/d')) + ->mapper() + ->map(DateTimeInterface::class, '2022/08/05'); + } catch (MappingError $error) { + $this->mappingFail($error); } + + self::assertSame('2022/08/05', $result->format('Y/m/d')); } - public function test_invalid_datetime_from_array_throws_exception(): void + public function test_default_date_constructor_with_invalid_source_throws_exception(): void { try { (new MapperBuilder()) ->mapper() - ->map(DateTimeInterface::class, [ - 'datetime' => 1337, - 'format' => 'H', - ]); + ->map(DateTimeInterface::class, 'invalid datetime'); } catch (MappingError $exception) { $error = $exception->node()->messages()[0]; self::assertSame('1630686564', $error->code()); - self::assertSame("Value 1337 does not match a valid date format.", (string)$error); + self::assertSame("Value 'invalid datetime' does not match any of the following formats: `Y-m-d\TH:i:sP`, `U`.", (string)$error); } } - public function test_invalid_array_source_throws_exception(): void + public function test_registered_date_constructor_with_invalid_source_throws_exception(): void { try { (new MapperBuilder()) + ->registerConstructor(new DateTimeFormatConstructor('Y/m/d')) ->mapper() - ->map(DateTimeInterface::class, [ - 'dateTime' => [ - 'invalid key' => '2012-12-21T13:37:42+00:00', - ], - ]); + ->map(DateTimeInterface::class, 'invalid datetime'); } catch (MappingError $exception) { - $error = $exception->node()->children()['value']->messages()[0]; + $error = $exception->node()->messages()[0]; - self::assertSame('1607027306', $error->code()); + self::assertSame('1630686564', $error->code()); + self::assertSame("Value 'invalid datetime' does not match any of the following formats: `Y/m/d`.", (string)$error); } } - - private function buildRandomTimestamp(): int - { - return random_int(1, 32503726800); - } -} - -final class AllDateTimeValues -{ - public DateTimeInterface $dateTimeInterface; - - public DateTimeImmutable $dateTimeImmutable; - - public DateTime $dateTimeFromTimestamp; - - public DateTime $dateTimeFromTimestampWithOutFormat; - - public DateTime $dateTimeFromTimestampWithFormat; - - public DateTimeInterface $dateTimeFromAtomFormat; - - public DateTimeInterface $dateTimeFromArray; - - public DateTimeInterface $mysqlDate; - - public DateTimeInterface $pgsqlDate; - - public DateTimeImmutable $sqlDateNotTime; } diff --git a/tests/Unit/Mapper/Object/DateTimeObjectBuilderTest.php b/tests/Unit/Mapper/Object/DateTimeObjectBuilderTest.php deleted file mode 100644 index 461e3626..00000000 --- a/tests/Unit/Mapper/Object/DateTimeObjectBuilderTest.php +++ /dev/null @@ -1,29 +0,0 @@ -describeArguments(); - $argumentsB = $objectBuilder->describeArguments(); - - self::assertSame($argumentsA, $argumentsB); - } - - public function test_signature_is_correct(): void - { - $objectBuilder = new DateTimeObjectBuilder(DateTimeImmutable::class); - - self::assertSame('Internal date object builder', $objectBuilder->signature()); - } -}