Skip to content

Commit 7e1ddf7

Browse files
authored
Add Date, DateTime, DateTimeTz scalars (#12)
1 parent 8f18ed3 commit 7e1ddf7

File tree

9 files changed

+374
-0
lines changed

9 files changed

+374
-0
lines changed

phpstan.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ parameters:
33
paths:
44
- src
55
- tests
6+
7+
ignoreErrors:
8+
# Safe\DateTime(Immutable)? - creates overhead and is not stable, prefer native classes
9+
- '~Class DateTimeImmutable is unsafe to use. Its methods can return FALSE instead of throwing an exception~'

src/Date.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\GraphQLScalars;
4+
5+
class Date extends DateScalar
6+
{
7+
public $description /** @lang Markdown */
8+
= 'A date string with format `Y-m-d`, e.g. `2011-05-23`.';
9+
10+
protected static function outputFormat(): string
11+
{
12+
return 'Y-m-d';
13+
}
14+
15+
protected static function regex(): string
16+
{
17+
return '~^(?<date>\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][\d]|3[01]))$~';
18+
}
19+
}

src/DateScalar.php

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\GraphQLScalars;
4+
5+
use DateTimeImmutable;
6+
use DateTimeInterface;
7+
use Exception;
8+
use GraphQL\Error\Error;
9+
use GraphQL\Error\InvariantViolation;
10+
use GraphQL\Language\AST\StringValueNode;
11+
use GraphQL\Type\Definition\ScalarType;
12+
use GraphQL\Utils\Utils;
13+
use function is_string;
14+
use function Safe\preg_match;
15+
use function Safe\substr;
16+
17+
abstract class DateScalar extends ScalarType
18+
{
19+
public function serialize($value): string
20+
{
21+
if (! $value instanceof DateTimeInterface) {
22+
$value = $this->tryParsingDate($value, InvariantViolation::class);
23+
}
24+
25+
return $value->format(static::outputFormat());
26+
}
27+
28+
public function parseValue($value): DateTimeInterface
29+
{
30+
return $this->tryParsingDate($value, Error::class);
31+
}
32+
33+
public function parseLiteral($valueNode, ?array $variables = null): DateTimeInterface
34+
{
35+
if (! $valueNode instanceof StringValueNode) {
36+
throw new Error(
37+
"Query error: Can only parse strings, got {$valueNode->kind}",
38+
$valueNode
39+
);
40+
}
41+
42+
return $this->tryParsingDate($valueNode->value, Error::class);
43+
}
44+
45+
/**
46+
* @template T of Error|InvariantViolation
47+
*
48+
* @param mixed $value Any value that might be a Date
49+
* @param class-string<T> $exceptionClass
50+
*
51+
* @throws T
52+
*/
53+
protected function tryParsingDate($value, string $exceptionClass): DateTimeInterface
54+
{
55+
if (is_string($value)) {
56+
if (1 !== preg_match(static::regex(), $value, $matches)) {
57+
$regex = static::regex();
58+
throw new $exceptionClass("Value \"${value}\" does not match \"{$regex}\". Make sure it's ISO 8601 compliant ");
59+
}
60+
61+
if (! $this->validateDate($matches['date'])) {
62+
$safeValue = Utils::printSafeJson($value);
63+
64+
throw new $exceptionClass("Expected input value to be ISO 8601 compliant. Given invalid value \"{$safeValue}\"");
65+
}
66+
67+
try {
68+
return new DateTimeImmutable($value);
69+
} catch (Exception $e) {
70+
throw new $exceptionClass($e->getMessage());
71+
}
72+
}
73+
74+
$safeValue = Utils::printSafeJson($value);
75+
76+
throw new $exceptionClass("Cannot parse non-string into date: {$safeValue}");
77+
}
78+
79+
abstract protected static function outputFormat(): string;
80+
81+
abstract protected static function regex(): string;
82+
83+
private function validateDate(string $date): bool
84+
{
85+
// Verify the correct number of days for the month contained in the date-string.
86+
$year = (int) substr($date, 0, 4);
87+
$month = (int) substr($date, 5, 2);
88+
$day = (int) substr($date, 8, 2);
89+
90+
switch ($month) {
91+
case 2: // February
92+
$isLeapYear = $this->isLeapYear($year);
93+
if ($isLeapYear && $day > 29) {
94+
return false;
95+
}
96+
97+
return $isLeapYear || $day <= 28;
98+
99+
case 4: // April
100+
case 6: // June
101+
case 9: // September
102+
case 11: // November
103+
if ($day > 30) {
104+
return false;
105+
}
106+
107+
break;
108+
}
109+
110+
return true;
111+
}
112+
113+
private function isLeapYear(int $year): bool
114+
{
115+
return (0 === $year % 4 && 0 !== $year % 100)
116+
|| 0 === $year % 400;
117+
}
118+
}

src/DateTime.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\GraphQLScalars;
4+
5+
class DateTime extends DateScalar
6+
{
7+
public $description /** @lang Markdown */
8+
= 'A datetime string with format `Y-m-d H:i:s`, e.g. `2018-05-23 13:43:32`.';
9+
10+
protected static function outputFormat(): string
11+
{
12+
return 'Y-m-d H:i:s';
13+
}
14+
15+
protected static function regex(): string
16+
{
17+
return '~^(?<date>\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][\d]|3[01])) ([01][\d]|2[0-3]):([0-5][\d]):([0-5][\d]|60)$~';
18+
}
19+
}

src/DateTimeTz.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\GraphQLScalars;
4+
5+
class DateTimeTz extends DateScalar
6+
{
7+
public $description /** @lang Markdown */
8+
= 'The `DateTime` scalar type represents time data, represented as an ISO-8601 encoded UTC date string.';
9+
10+
protected static function outputFormat(): string
11+
{
12+
return 'Y-m-d\TH:i:s.uP';
13+
}
14+
15+
protected static function regex(): string
16+
{
17+
return '~^((?<date>\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][\d]|3[01]))T([01][\d]|2[0-3]):([0-5][\d]):([0-5][\d]|60))(\.\d+)?(([Z])|([+|-]([01][\d]|2[0-3]):[0-5][\d]))$~';
18+
}
19+
}

tests/DateScalarTest.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\GraphQLScalars\Tests;
4+
5+
use DateTimeImmutable;
6+
use GraphQL\Error\Error;
7+
use GraphQL\Error\InvariantViolation;
8+
use GraphQL\Language\AST\IntValueNode;
9+
use GraphQL\Language\AST\StringValueNode;
10+
use MLL\GraphQLScalars\DateScalar;
11+
use PHPUnit\Framework\TestCase;
12+
13+
abstract class DateScalarTest extends TestCase
14+
{
15+
/**
16+
* @dataProvider invalidDateValues
17+
*
18+
* @param mixed $value An invalid value for a date
19+
*/
20+
public function testThrowsIfSerializingInvalidDates($value): void
21+
{
22+
$this->expectException(InvariantViolation::class);
23+
24+
$this->scalarInstance()->serialize($value);
25+
}
26+
27+
/**
28+
* @dataProvider invalidDateValues
29+
*
30+
* @param mixed $value An invalid value for a date
31+
*/
32+
public function testThrowsIfParseValueInvalidDate($value): void
33+
{
34+
$this->expectException(Error::class);
35+
36+
$this->scalarInstance()->parseValue($value);
37+
}
38+
39+
/**
40+
* Those values should fail passing as a date.
41+
*
42+
* @return iterable<array-key, array{mixed}>
43+
*/
44+
public function invalidDateValues(): iterable
45+
{
46+
yield [1];
47+
yield ['rolf'];
48+
yield [null];
49+
yield [''];
50+
}
51+
52+
/**
53+
* @dataProvider validDates
54+
*/
55+
public function testParsesValueString(string $value, string $expected): void
56+
{
57+
$parsedValue = $this->scalarInstance()->parseValue($value);
58+
59+
self::assertSame($expected, $parsedValue->format('Y-m-d\TH:i:s.uP'));
60+
}
61+
62+
/**
63+
* @dataProvider validDates
64+
*/
65+
public function testParsesLiteral(string $value, string $expected): void
66+
{
67+
$dateLiteral = new StringValueNode(
68+
['value' => $value]
69+
);
70+
$parsed = $this->scalarInstance()->parseLiteral($dateLiteral);
71+
72+
self::assertSame($expected, $parsed->format('Y-m-d\TH:i:s.uP'));
73+
}
74+
75+
public function testThrowsIfParseLiteralNonString(): void
76+
{
77+
$this->expectException(Error::class);
78+
79+
$this->scalarInstance()->parseLiteral(
80+
new IntValueNode([])
81+
);
82+
}
83+
84+
public function testSerializesDateTimeInterfaceInstance(): void
85+
{
86+
$now = new DateTimeImmutable();
87+
$result = $this->scalarInstance()->serialize($now);
88+
89+
self::assertNotEmpty($result);
90+
}
91+
92+
/**
93+
* The specific instance under test.
94+
*/
95+
abstract protected function scalarInstance(): DateScalar;
96+
97+
/**
98+
* Data provider for valid date strings and expected dates.
99+
*
100+
* @return iterable<array{string, string}>
101+
*/
102+
abstract public function validDates(): iterable;
103+
}

tests/DateTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\GraphQLScalars\Tests;
4+
5+
use MLL\GraphQLScalars\Date;
6+
use MLL\GraphQLScalars\DateScalar;
7+
8+
final class DateTest extends DateScalarTest
9+
{
10+
public function invalidDateValues(): iterable
11+
{
12+
yield from parent::invalidDateValues();
13+
14+
yield "Can't have 29th February" => ['2021-02-29'];
15+
yield "Can't have 30th February" => ['2020-02-30'];
16+
yield "Can't have 31th November" => ['2020-11-31'];
17+
yield 'DateTime' => ['2020-02-01 01:02:03'];
18+
yield 'DateTimeTz' => ['2017-02-01T00:00:00Z'];
19+
}
20+
21+
protected function scalarInstance(): DateScalar
22+
{
23+
return new Date();
24+
}
25+
26+
public function validDates(): iterable
27+
{
28+
yield ['2020-04-20', '2020-04-20T00:00:00.000000+00:00'];
29+
}
30+
}

tests/DateTimeTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\GraphQLScalars\Tests;
4+
5+
use MLL\GraphQLScalars\DateScalar;
6+
use MLL\GraphQLScalars\DateTime;
7+
8+
final class DateTimeTest extends DateScalarTest
9+
{
10+
public function invalidDateValues(): iterable
11+
{
12+
yield from parent::invalidDateValues();
13+
14+
yield "Can't have 30th February" => ['2020-02-30 01:02:03'];
15+
yield 'Date' => ['2020-02-01'];
16+
yield 'DateTimeTz' => ['2017-02-01T00:00:00Z'];
17+
}
18+
19+
protected function scalarInstance(): DateScalar
20+
{
21+
return new DateTime();
22+
}
23+
24+
public function validDates(): iterable
25+
{
26+
yield ['2020-04-20 23:51:15', '2020-04-20T23:51:15.000000+00:00'];
27+
}
28+
}

tests/DateTimeTzTest.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\GraphQLScalars\Tests;
4+
5+
use MLL\GraphQLScalars\DateScalar;
6+
use MLL\GraphQLScalars\DateTimeTz;
7+
8+
final class DateTimeTzTest extends DateScalarTest
9+
{
10+
public function invalidDateValues(): iterable
11+
{
12+
yield from parent::invalidDateValues();
13+
14+
yield "Can't have 30th February" => ['2020-02-30T00:00:00Z'];
15+
yield 'Date' => ['2020-02-01'];
16+
yield 'DateTime' => ['2020-02-01 01:02:03'];
17+
yield '001st' => ['2017-01-001T00:00:00Z'];
18+
}
19+
20+
protected function scalarInstance(): DateScalar
21+
{
22+
return new DateTimeTz();
23+
}
24+
25+
public function validDates(): iterable
26+
{
27+
yield ['2020-04-20T16:20:04+04:00', '2020-04-20T16:20:04.000000+04:00'];
28+
yield ['2020-04-20T16:20:04Z', '2020-04-20T16:20:04.000000+00:00'];
29+
yield ['2020-04-20T16:20:04.0Z', '2020-04-20T16:20:04.000000+00:00'];
30+
yield ['2020-04-20T16:20:04.000Z', '2020-04-20T16:20:04.000000+00:00'];
31+
yield ['2020-04-20T16:20:04.987Z', '2020-04-20T16:20:04.987000+00:00'];
32+
yield ['2020-04-20T16:20:04.000000Z', '2020-04-20T16:20:04.000000+00:00'];
33+
}
34+
}

0 commit comments

Comments
 (0)