diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9e54c37..ea3301a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,7 @@ jobs: uses: yiisoft/actions/.github/workflows/phpunit.yml@master with: ini-values: pcov.directory=$GITHUB_WORKSPACE, pcov.exclude=#^(?!($GITHUB_WORKSPACE/config/|$GITHUB_WORKSPACE/src/)).*# + extensions: intl os: >- ['ubuntu-latest', 'windows-latest'] php: >- diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 68ad08d..c7fccb9 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -28,6 +28,6 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.2'] + ['8.3'] secrets: STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f19f9ec..9889b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 1.2.0 under development +- New #77: Add `ToDateTime` parameter attribute (@vjik) - Enh #76: Raise the minimum version of PHP to 8.1 (@vjik) ## 1.1.0 February 09, 2024 diff --git a/composer-require-checker.json b/composer-require-checker.json index 6b5459c..08b67ef 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -1,5 +1,6 @@ { "symbol-whitelist": [ - "Yiisoft\\Router\\CurrentRoute" + "Yiisoft\\Router\\CurrentRoute", + "IntlDateFormatter" ] } diff --git a/composer.json b/composer.json index 6bcefa9..70454f7 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,9 @@ "yiisoft/dummy-provider": "^1.0", "yiisoft/test-support": "^3.0" }, + "suggest": { + "ext-intl": "Allows using `ToDateTime` parameter attribute" + }, "autoload": { "psr-4": { "Yiisoft\\Hydrator\\": "src" diff --git a/docs/guide/en/typecasting.md b/docs/guide/en/typecasting.md index 4f3c4af..2abda87 100644 --- a/docs/guide/en/typecasting.md +++ b/docs/guide/en/typecasting.md @@ -138,3 +138,20 @@ $money = $hydrator->create(Money::class, [ 'currency' => 'AMD', ]); ``` + +To cast a value to `DateTimeImmutable` or `DateTime` object explicitly, you can use `ToDateTime` attribute: + +```php +use DateTimeImmutable; +use Yiisoft\Hydrator\Attribute\Parameter\ToDateTime; + +class Person +{ + public function __construct( + #[ToDateTime(locale: 'ru')] + private ?DateTimeImmutable $birthday = null, + ) {} +} + +$person = $hydrator->create(Person::class, ['birthday' => '27.01.1986']); +``` diff --git a/docs/guide/ru/typecasting.md b/docs/guide/ru/typecasting.md index 0b861ee..dd4f600 100644 --- a/docs/guide/ru/typecasting.md +++ b/docs/guide/ru/typecasting.md @@ -138,3 +138,20 @@ $money = $hydrator->create(Money::class, [ 'currency' => 'AMD', ]); ``` + +Для приведения значения к объекту `DateTimeImmutable` или `DateTime` явно, вы можете использовать атрибут `ToDateTime`: + +```php +use DateTimeImmutable; +use Yiisoft\Hydrator\Attribute\Parameter\ToDateTime; + +class Person +{ + public function __construct( + #[ToDateTime(locale: 'ru')] + private ?DateTimeImmutable $birthday = null, + ) {} +} + +$person = $hydrator->create(Person::class, ['birthday' => '27.01.1986']); +``` diff --git a/infection.json.dist b/infection.json.dist index 56d0f8f..d47479c 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -9,5 +9,13 @@ "stryker": { "report": "master" } + }, + "mutators": { + "@default": true, + "FalseValue": { + "ignoreSourceCodeByRegex": [ + ".*\\$hasMutable = false.*" + ] + } } } diff --git a/psalm.xml b/psalm.xml index a90a8b9..b48c894 100644 --- a/psalm.xml +++ b/psalm.xml @@ -8,12 +8,13 @@ xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" > - + - + + diff --git a/src/Attribute/Parameter/ToDateTime.php b/src/Attribute/Parameter/ToDateTime.php new file mode 100644 index 0000000..37adb74 --- /dev/null +++ b/src/Attribute/Parameter/ToDateTime.php @@ -0,0 +1,36 @@ +isResolved()) { + return Result::fail(); + } + + $resolvedValue = $context->getResolvedValue(); + $shouldBeMutable = $this->shouldResultBeMutable($context); + + if ($resolvedValue instanceof DateTimeInterface) { + return $this->createSuccessResult($resolvedValue, $shouldBeMutable); + } + + $timeZone = $attribute->timeZone ?? $this->timeZone; + if ($timeZone !== null) { + $timeZone = new DateTimeZone($timeZone); + } + + if (is_int($resolvedValue)) { + return Result::success( + $this->makeDateTimeFromTimestamp($resolvedValue, $timeZone, $shouldBeMutable) + ); + } + + if (is_string($resolvedValue) && !empty($resolvedValue)) { + $format = $attribute->format ?? $this->format; + if (is_string($format) && str_starts_with($format, 'php:')) { + return $this->parseWithPhpFormat($resolvedValue, substr($format, 4), $timeZone, $shouldBeMutable); + } + return $this->parseWithIntlFormat( + $resolvedValue, + $format, + $attribute->dateType ?? $this->dateType, + $attribute->timeType ?? $this->timeType, + $timeZone, + $attribute->locale ?? $this->locale, + $shouldBeMutable, + ); + } + + return Result::fail(); + } + + /** + * @psalm-param non-empty-string $resolvedValue + */ + private function parseWithPhpFormat( + string $resolvedValue, + string $format, + ?DateTimeZone $timeZone, + bool $shouldBeMutable, + ): Result { + $date = $shouldBeMutable + ? DateTime::createFromFormat($format, $resolvedValue, $timeZone) + : DateTimeImmutable::createFromFormat($format, $resolvedValue, $timeZone); + if ($date === false) { + return Result::fail(); + } + + $errors = DateTimeImmutable::getLastErrors(); + if (!empty($errors['warning_count'])) { + return Result::fail(); + } + + // If no time was provided in the format string set time to 0 + if (!strpbrk($format, 'aAghGHisvuU')) { + $date = $date->setTime(0, 0); + } + + return Result::success($date); + } + + /** + * @psalm-param non-empty-string $resolvedValue + * @psalm-param IntlDateFormatterFormat $dateType + * @psalm-param IntlDateFormatterFormat $timeType + */ + private function parseWithIntlFormat( + string $resolvedValue, + ?string $format, + int $dateType, + int $timeType, + ?DateTimeZone $timeZone, + ?string $locale, + bool $shouldBeMutable, + ): Result { + $formatter = $format === null + ? new IntlDateFormatter($locale, $dateType, $timeType, $timeZone) + : new IntlDateFormatter( + $locale, + IntlDateFormatter::NONE, + IntlDateFormatter::NONE, + $timeZone, + pattern: $format + ); + $formatter->setLenient(false); + $timestamp = $formatter->parse($resolvedValue); + return is_int($timestamp) + ? Result::success($this->makeDateTimeFromTimestamp($timestamp, $timeZone, $shouldBeMutable)) + : Result::fail(); + } + + private function makeDateTimeFromTimestamp( + int $timestamp, + ?DateTimeZone $timeZone, + bool $shouldBeMutable + ): DateTimeInterface { + /** + * @psalm-suppress InvalidNamedArgument Psalm bug: https://github.com/vimeo/psalm/issues/10872 + */ + return $shouldBeMutable + ? (new DateTime(timezone: $timeZone))->setTimestamp($timestamp) + : (new DateTimeImmutable(timezone: $timeZone))->setTimestamp($timestamp); + } + + private function createSuccessResult(DateTimeInterface $date, bool $shouldBeMutable): Result + { + if ($shouldBeMutable) { + return Result::success( + $date instanceof DateTime ? $date : DateTime::createFromInterface($date) + ); + } + return Result::success( + $date instanceof DateTimeImmutable ? $date : DateTimeImmutable::createFromInterface($date) + ); + } + + private function shouldResultBeMutable(ParameterAttributeResolveContext $context): bool + { + $type = $context->getParameter()->getType(); + + if ($type instanceof ReflectionNamedType && $type->getName() === DateTime::class) { + return true; + } + + if ($type instanceof ReflectionUnionType) { + $hasMutable = false; + /** + * @psalm-suppress RedundantConditionGivenDocblockType Need for PHP 8.1 + */ + foreach ($type->getTypes() as $subType) { + if ($subType instanceof ReflectionNamedType) { + switch ($subType->getName()) { + case DateTime::class: + $hasMutable = true; + break; + case DateTimeImmutable::class: + case DateTimeInterface::class: + return false; + } + } + } + return $hasMutable; + } + + return false; + } +} diff --git a/tests/Attribute/Parameter/ToDateTimeTest.php b/tests/Attribute/Parameter/ToDateTimeTest.php new file mode 100644 index 0000000..ad7ebf6 --- /dev/null +++ b/tests/Attribute/Parameter/ToDateTimeTest.php @@ -0,0 +1,372 @@ + [ + new DateTimeImmutable('04/01/2024'), + new ToDateTime(), + new DateTime('04/01/2024'), + ]; + yield 'DateTimeImmutable' => [ + new DateTimeImmutable('04/01/2024'), + new ToDateTime(), + new DateTimeImmutable('04/01/2024'), + ]; + yield 'string-php-format' => [ + new DateTimeImmutable('04/01/2024'), + new ToDateTime(format: 'php:m/d/Y'), + '04/01/2024', + ]; + yield 'string-intl-format' => [ + new DateTimeImmutable('04/01/2024'), + new ToDateTime(format: 'MM/dd/yyyy'), + '04/01/2024', + ]; + yield 'timestamp-integer' => [ + (new DateTimeImmutable())->setTimestamp(1711972838), + new ToDateTime(), + 1711972838, + ]; + yield 'timezone' => [ + new DateTimeImmutable('12.11.2003, 07:20', new DateTimeZone('UTC')), + new ToDateTime(format: 'php:d.m.Y, H:i', timeZone: 'GMT+5'), + '12.11.2003, 12:20', + ]; + yield 'locale-ru' => [ + new DateTimeImmutable('12.11.2020, 12:20'), + new ToDateTime(locale: 'ru'), + '12.11.2020, 12:20', + ]; + } + + #[DataProvider('dataBase')] + public function testBase(DateTimeImmutable $expected, ToDateTime $attribute, mixed $value): void + { + $resolver = new ToDateTimeResolver(); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter(static fn(?DateTimeImmutable $a) => null), + Result::success($value), + new ArrayData(), + ); + + $result = $resolver->getParameterValue($attribute, $context); + + $this->assertTrue($result->isResolved()); + $this->assertEquals($expected, $result->getValue()); + } + + public function testWithHydrator(): void + { + $hydrator = new Hydrator(); + $object = new class () { + #[ToDateTime(format: 'php:d.m.Y')] + public ?DateTimeImmutable $a = null; + }; + + $hydrator->hydrate($object, ['a' => '12.11.2003']); + + $this->assertInstanceOf(DateTimeImmutable::class, $object->a); + $this->assertEquals(new DateTimeImmutable('12.11.2003'), $object->a); + } + + public static function dataNotResolve(): iterable + { + yield 'invalid-string' => ['12-11-2003']; + yield 'invalid-date' => ['30.02.2021']; + yield 'not-supported-type' => [new stdClass()]; + } + + #[DataProvider('dataNotResolve')] + public function testNotResolvePhpFormat(mixed $value): void + { + $hydrator = new Hydrator(); + $object = new class () { + #[ToDateTime(format: 'php:d.m.Y')] + public ?DateTimeImmutable $a = null; + }; + + $hydrator->hydrate($object, ['a' => $value]); + + $this->assertNull($object->a); + } + + #[DataProvider('dataNotResolve')] + public function testNotResolveIntlFormat(mixed $value): void + { + $hydrator = new Hydrator(); + $object = new class () { + #[ToDateTime(format: 'dd.MM.yyyy')] + public ?DateTimeImmutable $a = null; + }; + + $hydrator->hydrate($object, ['a' => $value]); + + $this->assertNull($object->a); + } + + public function testNotResolvedValue(): void + { + $hydrator = new Hydrator(); + $object = new class () { + #[ToDateTime(format: 'php:d.m.Y')] + public ?DateTimeImmutable $a = null; + }; + + $hydrator->hydrate($object, ['b' => '12.11.2003']); + + $this->assertNull($object->a); + } + + public function testDefaultFormat(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + ToDateTimeResolver::class => new ToDateTimeResolver(format: 'php:Y?m?d'), + ]), + ), + ); + $object = new class () { + #[ToDateTime] + public ?DateTimeImmutable $a = null; + }; + + $hydrator->hydrate($object, ['a' => '2003x11x12']); + + $this->assertEquals(new DateTimeImmutable('12.11.2003'), $object->a); + } + + public function testOverrideDefaultTimeZone(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + ToDateTimeResolver::class => new ToDateTimeResolver(timeZone: 'GMT+3'), + ]), + ), + ); + $object = new class () { + #[ToDateTime(locale: 'ru', timeZone: 'UTC')] + public ?DateTimeImmutable $a = null; + }; + + $hydrator->hydrate($object, ['a' => '12.11.2003, 12:34']); + + $this->assertEquals(new DateTimeImmutable('12.11.2003, 12:34', new DateTimeZone('UTC')), $object->a); + } + + public function testOverrideDefaultLocale(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + ToDateTimeResolver::class => new ToDateTimeResolver(locale: 'en'), + ]), + ), + ); + $object = new class () { + #[ToDateTime(locale: 'ru')] + public ?DateTimeImmutable $a = null; + }; + + $hydrator->hydrate($object, ['a' => '12.11.2003, 12:20']); + + $this->assertEquals(new DateTimeImmutable('12.11.2003, 12:20'), $object->a); + } + + public function testOverrideDefaultFormat(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + ToDateTimeResolver::class => new ToDateTimeResolver(format: 'php:Y-m-d'), + ]), + ), + ); + $object = new class () { + #[ToDateTime(format: 'php:d.m.Y')] + public ?DateTimeImmutable $a = null; + }; + + $hydrator->hydrate($object, ['a' => '12.11.2003']); + + $this->assertEquals(new DateTimeImmutable('12.11.2003'), $object->a); + } + + public function testUnexpectedAttributeException(): void + { + $hydrator = new Hydrator( + attributeResolverFactory: new ContainerAttributeResolverFactory( + new SimpleContainer([ + CounterResolver::class => new ToDateTimeResolver(), + ]), + ), + ); + $object = new CounterClass(); + + $this->expectException(UnexpectedAttributeException::class); + $this->expectExceptionMessage( + 'Expected "' . ToDateTime::class . '", but "' . Counter::class . '" given.' + ); + $hydrator->hydrate($object); + } + + public static function dataResultType(): iterable + { + yield 'immutable-to-immutable' => [ + DateTimeImmutable::class, + static fn(DateTimeImmutable $a) => null, + new DateTimeImmutable(), + ]; + yield 'immutable-to-nullable-immutable' => [ + DateTimeImmutable::class, + static fn(?DateTimeImmutable $a) => null, + new DateTimeImmutable(), + ]; + yield 'mutable-to-immutable' => [ + DateTimeImmutable::class, + static fn(DateTimeImmutable $a) => null, + new DateTime(), + ]; + yield 'mutable-to-nullable-immutable' => [ + DateTimeImmutable::class, + static fn(?DateTimeImmutable $a) => null, + new DateTime(), + ]; + yield 'string-to-immutable' => [ + DateTimeImmutable::class, + static fn(DateTimeImmutable $a) => null, + '12.11.2003', + ]; + yield 'immutable-to-mutable' => [ + DateTime::class, + static fn(DateTime $a) => null, + new DateTimeImmutable(), + ]; + yield 'immutable-to-nullable-mutable' => [ + DateTime::class, + static fn(?DateTime $a) => null, + new DateTimeImmutable(), + ]; + yield 'mutable-to-mutable' => [ + DateTime::class, + static fn(DateTime $a) => null, + new DateTime(), + ]; + yield 'mutable-to-nullable-mutable' => [ + DateTime::class, + static fn(?DateTime $a) => null, + new DateTime(), + ]; + yield 'string-to-mutable' => [ + DateTime::class, + static fn(DateTime $a) => null, + '12.11.2003', + ]; + yield 'immutable-to-interface' => [ + DateTimeImmutable::class, + static fn(DateTimeInterface $a) => null, + new DateTimeImmutable(), + ]; + yield 'immutable-to-nullable-interface' => [ + DateTimeImmutable::class, + static fn(?DateTimeInterface $a) => null, + new DateTimeImmutable(), + ]; + yield 'mutable-to-interface' => [ + DateTimeImmutable::class, + static fn(DateTimeInterface $a) => null, + new DateTime(), + ]; + yield 'mutable-to-nullable-interface' => [ + DateTimeImmutable::class, + static fn(?DateTimeInterface $a) => null, + new DateTime(), + ]; + yield 'string-to-interface' => [ + DateTimeImmutable::class, + static fn(DateTimeInterface $a) => null, + '12.11.2003', + ]; + yield 'string-to-interface-and-mutable' => [ + DateTimeImmutable::class, + static fn(DateTimeInterface|DateTime $a) => null, + '12.11.2003', + ]; + yield 'string-to-immutable-and-mutable' => [ + DateTimeImmutable::class, + static fn(DateTimeImmutable|DateTime $a) => null, + '12.11.2003', + ]; + yield 'string-to-mutable-and-immutable' => [ + DateTimeImmutable::class, + static fn(DateTime|DateTimeImmutable $a) => null, + '12.11.2003', + ]; + yield 'string-to-int-and-mutable' => [ + DateTime::class, + static fn(int|DateTime $a) => null, + '12.11.2003', + ]; + } + + #[DataProvider('dataResultType')] + public function testResultType(string $expected, Closure $closure, mixed $value): void + { + $resolver = new ToDateTimeResolver(); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter($closure), + Result::success($value), + new ArrayData(), + ); + + $result = $resolver->getParameterValue(new ToDateTime(format: 'php:d.m.Y'), $context); + + $this->assertTrue($result->isResolved()); + $this->assertInstanceOf($expected, $result->getValue()); + } + + #[DataProvider('dataResultType')] + public function testResultTypeWithIntlFormat(string $expected, Closure $closure, mixed $value): void + { + $resolver = new ToDateTimeResolver(); + $context = new ParameterAttributeResolveContext( + TestHelper::getFirstParameter($closure), + Result::success($value), + new ArrayData(), + ); + + $result = $resolver->getParameterValue(new ToDateTime(format: 'dd.MM.yyyy'), $context); + + $this->assertTrue($result->isResolved()); + $this->assertInstanceOf($expected, $result->getValue()); + } +}