Skip to content

Commit

Permalink
Add ToDateTime parameter attribute (#77)
Browse files Browse the repository at this point in the history
Co-authored-by: Dmitriy Derepko <[email protected]>
  • Loading branch information
vjik and xepozz authored Apr 3, 2024
1 parent c1c28a5 commit c51e692
Show file tree
Hide file tree
Showing 12 changed files with 661 additions and 4 deletions.
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >-
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ jobs:
os: >-
['ubuntu-latest']
php: >-
['8.2']
['8.3']
secrets:
STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion composer-require-checker.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"symbol-whitelist": [
"Yiisoft\\Router\\CurrentRoute"
"Yiisoft\\Router\\CurrentRoute",
"IntlDateFormatter"
]
}
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions docs/guide/en/typecasting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
```
17 changes: 17 additions & 0 deletions docs/guide/ru/typecasting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
```
8 changes: 8 additions & 0 deletions infection.json.dist
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,13 @@
"stryker": {
"report": "master"
}
},
"mutators": {
"@default": true,
"FalseValue": {
"ignoreSourceCodeByRegex": [
".*\\$hasMutable = false.*"
]
}
}
}
5 changes: 3 additions & 2 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
>
<projectFiles>
<directory name="src"/>
<directory name="src" />
<ignoreFiles>
<directory name="vendor"/>
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
<issueHandlers>
<MixedAssignment errorLevel="suppress" />
<RiskyTruthyFalsyComparison errorLevel="suppress" />
</issueHandlers>
</psalm>
36 changes: 36 additions & 0 deletions src/Attribute/Parameter/ToDateTime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Hydrator\Attribute\Parameter;

use Attribute;
use IntlDateFormatter;

/**
* Converts the resolved value to `DateTimeImmutable` object. Non-resolved and invalid values are skipped.
*
* @psalm-type IntlDateFormatterFormat = IntlDateFormatter::FULL | IntlDateFormatter::LONG | IntlDateFormatter::MEDIUM | IntlDateFormatter::SHORT | IntlDateFormatter::NONE
*/
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::IS_REPEATABLE)]
final class ToDateTime implements ParameterAttributeInterface
{
/**
* @psalm-param IntlDateFormatterFormat|null $dateType
* @psalm-param IntlDateFormatterFormat|null $timeType
* @psalm-param non-empty-string|null $timeZone
*/
public function __construct(
public readonly ?string $format = null,
public readonly ?int $dateType = null,
public readonly ?int $timeType = null,
public readonly ?string $timeZone = null,
public readonly ?string $locale = null,
) {
}

public function getResolver(): string
{
return ToDateTimeResolver::class;
}
}
200 changes: 200 additions & 0 deletions src/Attribute/Parameter/ToDateTimeResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Hydrator\Attribute\Parameter;

use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use IntlDateFormatter;
use ReflectionNamedType;
use ReflectionUnionType;
use Yiisoft\Hydrator\AttributeHandling\Exception\UnexpectedAttributeException;
use Yiisoft\Hydrator\AttributeHandling\ParameterAttributeResolveContext;
use Yiisoft\Hydrator\Result;

/**
* @psalm-import-type IntlDateFormatterFormat from ToDateTime
*/
final class ToDateTimeResolver implements ParameterAttributeResolverInterface
{
/**
* @psalm-param IntlDateFormatterFormat $dateType
* @psalm-param IntlDateFormatterFormat $timeType
* @psalm-param non-empty-string|null $timeZone
*/
public function __construct(
private readonly ?string $format = null,
private readonly int $dateType = IntlDateFormatter::SHORT,
private readonly int $timeType = IntlDateFormatter::SHORT,
private readonly ?string $timeZone = null,
private readonly ?string $locale = null,
) {
}

public function getParameterValue(
ParameterAttributeInterface $attribute,
ParameterAttributeResolveContext $context
): Result {
if (!$attribute instanceof ToDateTime) {
throw new UnexpectedAttributeException(ToDateTime::class, $attribute);
}

if (!$context->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;
}
}
Loading

0 comments on commit c51e692

Please sign in to comment.