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());
+ }
+}