Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ToDateTime parameter attribute #77

Merged
merged 22 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading