Skip to content

Commit 2df12b5

Browse files
committed
Timezone support updates and additional tests
1 parent 582add6 commit 2df12b5

File tree

7 files changed

+418
-28
lines changed

7 files changed

+418
-28
lines changed

README.md

+28
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ If you need to continue running applications on that infrastructure, stick to ve
4848
* [Getting Started](#getting-started) including installation with Composer and setup for GDS Emulator
4949
* [Defining Your Model](#defining-your-model)
5050
* [Creating Records](#creating-records)
51+
* [Timezones and DateTime](#updated-timezone-support)
5152
* [Geopoint Support](#geopoint)
5253
* [Queries, GQL & The Default Query](#queries-gql--the-default-query)
5354
* [Multi-tenant Applications & Data Namespaces](#multi-tenant-applications--data-namespaces)
@@ -143,6 +144,33 @@ Code: https://github.com/tomwalder/php-gds-demo
143144
* Remove PHP 5 support
144145
* Remove App Engine first-generation runtime support (inc direct Protocol Buffer API)
145146

147+
### Updated Timezone Support ###
148+
149+
In 5.1, timezone support has been improved for `DateTime` objects going in & out of Datastore.
150+
151+
#### How the data is stored
152+
Datstore keeps the data recorded as UTC. When you browse data in the Google Cloud Console, they represent it in your locale.
153+
154+
#### Data coming out through PHP-GDS as Entities
155+
You can now expect any `DateTime` object coming out of Datastore from PHP-GDS to have your current PHP default timezone applied. Example follows:
156+
157+
```php
158+
date_default_timezone_set('America/New_York');
159+
160+
$obj_store = new GDS\Store('Book');
161+
$obj_book = $obj_store->fetchOne();
162+
echo $obj_book->published->format('c'); // 2004-02-12T15:19:21-05:00
163+
echo $obj_book->published->getTimezone()->getName(); // America/New_York
164+
```
165+
166+
#### Data going in - multi format support
167+
If you pass in a `DateTime` object (or anything matching `DateTimeInterface`), we will respect the timezone set on it.
168+
169+
Any other string-based value passed in for a `datetime` field will be converted to a `DateTimeImmutable` object before being converted to UTC, using the standard PHP methods:
170+
https://www.php.net/manual/en/datetime.construct.php
171+
172+
This means that unless using a timestamp value (e.g. `@946684800`), or a value with a timezone already stated (e.g. `2010-01-28T15:00:00+02:00`), we will assume the value is in your current timezone context.
173+
146174
## Changes in Version 4 ##
147175

148176
* More consistent use of `DateTime` objects - now all result sets will use them instead of `Y-m-d H:i:s` strings

src/GDS/Gateway/RESTv1.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ protected function configureObjectValueParamForQuery($obj_val, $mix_value)
432432
/** @var Entity $mix_value */
433433
$obj_val->keyValue = $this->applyPartition((object)['path' => $this->createMapper()->buildKeyPath($mix_value)]);
434434
} elseif ($mix_value instanceof \DateTimeInterface) {
435-
$obj_val->timestampValue = $mix_value->format(\GDS\Mapper\RESTv1::DATETIME_FORMAT);
435+
$obj_val->timestampValue = $mix_value->format(\GDS\Mapper\RESTv1::DATETIME_FORMAT_ZULU);
436436
} elseif (method_exists($mix_value, '__toString')) {
437437
$obj_val->stringValue = $mix_value->__toString();
438438
} else {

src/GDS/Mapper.php

+2-6
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,9 @@ abstract class Mapper
2828
/**
2929
* Datetime formats
3030
*/
31-
const DATETIME_FORMAT_UU = 'Uu';
3231
const DATETIME_FORMAT_UDOTU = 'U.u';
33-
34-
/**
35-
* Microseconds in a second
36-
*/
37-
const MICROSECONDS = 1000000;
32+
const TZ_UTC = 'UTC';
33+
const TZ_UTC_OFFSET = '+00:00';
3834

3935
/**
4036
* Current Schema

src/GDS/Mapper/GRPCv1.php

+8-2
Original file line numberDiff line numberDiff line change
@@ -343,13 +343,19 @@ private function configureGooglePropertyValue(array $arr_field_def, $mix_value)
343343
/**
344344
* Extract a datetime value
345345
*
346+
* Attempt to retain microsecond precision
347+
*
346348
* @param Value $obj_property
347349
* @return mixed
348350
*/
349351
protected function extractDatetimeValue($obj_property)
350352
{
351-
// Attempt to retain microsecond precision
352-
return $obj_property->getTimestampValue()->toDateTime();
353+
$obj_dtm = $obj_property->getTimestampValue()->toDateTime();
354+
$str_default_tz = date_default_timezone_get();
355+
if (self::TZ_UTC === $str_default_tz || self::TZ_UTC_OFFSET === $str_default_tz) {
356+
return $obj_dtm;
357+
}
358+
return $obj_dtm->setTimezone(new \DateTimeZone($str_default_tz));
353359
}
354360

355361
/**

src/GDS/Mapper/RESTv1.php

+31-10
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class RESTv1 extends \GDS\Mapper
3030
*
3131
* A timestamp in RFC3339 UTC "Zulu" format, accurate to nanoseconds. Example: "2014-10-02T15:01:23.045123456Z".
3232
*/
33-
const DATETIME_FORMAT = 'Y-m-d\TH:i:s.u\Z';
33+
const DATETIME_FORMAT_ZULU = 'Y-m-d\TH:i:s.u\Z';
3434

3535
/**
3636
* Auto detect & extract a value
@@ -71,22 +71,40 @@ protected function extractAutoDetectValue($obj_property)
7171
/**
7272
* Extract a datetime value
7373
*
74-
* We will lose accuracy
75-
* - past seconds in version 3.0
76-
* - past microseconds (down from nanoseconds) in version 4.0
74+
* Response values are ...
75+
* A timestamp in RFC3339 UTC "Zulu" format, accurate to nanoseconds. Example: "2014-10-02T15:01:23.045123456Z".
76+
*
77+
* Construct as UTC, then apply default timezone before returning
78+
*
79+
* PHP cannot handle more that 6 d.p. (microseconds), so we parse out as best we can with preg_match()
7780
*
7881
* @param $obj_property
7982
* @return mixed
8083
*/
8184
protected function extractDatetimeValue($obj_property)
85+
{
86+
return $this->buildLocalisedDateTimeObjectFromUTCString((string) $obj_property->timestampValue);
87+
}
88+
89+
/**
90+
* Build and return a DateTime, with the current timezone applied
91+
*
92+
* @param string $str_datetime
93+
* @return \DateTime
94+
* @throws \Exception
95+
*/
96+
public function buildLocalisedDateTimeObjectFromUTCString(string $str_datetime): \DateTime
8297
{
8398
$arr_matches = [];
84-
if(preg_match('/(.{19})\.?(\d{0,6}).*Z/', $obj_property->timestampValue, $arr_matches) > 0) {
85-
$obj_dtm = new \DateTime($arr_matches[1] . '.' . $arr_matches[2] . 'Z');
86-
} else {
87-
$obj_dtm = new \DateTime($obj_property->timestampValue);
99+
if(preg_match('/(.{19})\.?(\d{0,6}).*Z/', $str_datetime, $arr_matches) > 0) {
100+
$str_datetime = $arr_matches[1] . '.' . $arr_matches[2] . 'Z';
101+
}
102+
$str_default_tz = date_default_timezone_get();
103+
if (self::TZ_UTC === $str_default_tz || self::TZ_UTC_OFFSET === $str_default_tz) {
104+
new \DateTime($str_datetime);
88105
}
89-
return $obj_dtm;
106+
return (new \DateTime($str_datetime, new \DateTimeZone(self::TZ_UTC)))
107+
->setTimezone(new \DateTimeZone($str_default_tz));
90108
}
91109

92110
/**
@@ -399,7 +417,10 @@ protected function createPropertyValue(array $arr_field_def, $mix_value)
399417
$obj_dtm = new \DateTimeImmutable($mix_value);
400418
}
401419
// A timestamp in RFC3339 UTC "Zulu" format, accurate to nanoseconds. Example: "2014-10-02T15:01:23.045123456Z".
402-
$obj_property_value->timestampValue = $obj_dtm->format(self::DATETIME_FORMAT);
420+
$obj_property_value->timestampValue = \DateTime::createFromFormat(
421+
self::DATETIME_FORMAT_UDOTU,
422+
$obj_dtm->format(self::DATETIME_FORMAT_UDOTU)
423+
)->format(self::DATETIME_FORMAT_ZULU);
403424
break;
404425

405426
case Schema::PROPERTY_DOUBLE:

tests/RESTv1MapperTest.php

+134-9
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,63 @@ public function testDateTimeMapToGoogle()
122122

123123
}
124124

125+
/**
126+
* Test data going into Datastore has been correctly converted to UTC when operating in another TZ
127+
*/
128+
public function testDateTimeMapToGoogleWithTimezone()
129+
{
130+
// Let's use a timezone with no Daylight savings
131+
// This is -03:00 hours
132+
$str_existing_tz = date_default_timezone_get();
133+
date_default_timezone_set('America/Cayenne');
134+
135+
$obj_schema = (new \GDS\Schema('Person'))->addDatetime('retirement');
136+
137+
$obj_mapper = new \GDS\Mapper\RESTv1();
138+
$obj_mapper->setSchema($obj_schema);
139+
140+
$obj_gds_entity = new \GDS\Entity();
141+
$obj_gds_entity->setSchema($obj_schema);
142+
$obj_gds_entity->setKind('Person');
143+
144+
$obj_gds_entity->zoned = new DateTime('2021-02-04 08:30:00'); // takes on default timezone
145+
$obj_gds_entity->dob = new DateTime('1979-02-05T08:30:00+09:00'); // timezone specified
146+
$obj_gds_entity->exact = new DateTime('1979-02-05T08:30:00.12345678Z'); // UTC assumed
147+
$obj_gds_entity->ts = new DateTime('@946684800'); // UTC assumed
148+
$obj_gds_entity->retirement = '2050-01-01 09:00:00'; // takes on default timezone
149+
150+
$obj_rest_entity = $obj_mapper->mapToGoogle($obj_gds_entity);
151+
152+
$this->assertEquals(
153+
'2021-02-04T11:30:00.000000Z',
154+
$obj_rest_entity->properties->zoned->timestampValue,
155+
'08:30 (-3) => 11:30 UTC'
156+
);
157+
158+
// 1979-02-05T08:30:00+09:00 => previous day, 23:30
159+
$this->assertEquals(
160+
'1979-02-04T23:30:00.000000Z',
161+
$obj_rest_entity->properties->dob->timestampValue,
162+
'Previous day, 23:30'
163+
);
164+
165+
// '1979-02-05T08:30:00.123457Z' 6 OR 7, depending on PHP version (>= 7.2, cuts not rounds)
166+
$this->assertTrue(in_array($obj_rest_entity->properties->exact->timestampValue, [
167+
'1979-02-05T08:30:00.123456Z', // PHP >= 7.2
168+
'1979-02-05T08:30:00.123457Z', // PHP up to 7.1
169+
]));
170+
171+
//
172+
$this->assertEquals(
173+
'2050-01-01T12:00:00.000000Z',
174+
$obj_rest_entity->properties->retirement->timestampValue,
175+
'-3 hours from Y-m-d H:i:s'
176+
);
177+
178+
// Reset the timezone
179+
date_default_timezone_set($str_existing_tz);
180+
}
181+
125182
/**
126183
* Ensure arrays of lat/lon pairs are supported for geopoints
127184
*/
@@ -432,14 +489,82 @@ public function testAncestryFromArray()
432489
$this->assertEquals('Child', $obj_path_last->kind);
433490
}
434491

492+
/**
493+
* Confirm we correctly extract DateTime objects from REST responses
494+
*
495+
* @throws Exception
496+
*/
497+
public function testMapDatetimeFromGoogle()
498+
{
499+
$obj_schema = (new \GDS\Schema('Event'))->addDatetime('when');
500+
$obj_mapper = new \GDS\Mapper\RESTv1();
501+
$obj_mapper->setSchema($obj_schema);
502+
$obj_entity = $obj_mapper->mapOneFromResult($this->buildFakeResponse());
503+
$this->assertInstanceOf('\\DateTime', $obj_entity->when);
504+
$this->assertInstanceOf('\\DateTime', $obj_entity->then);
505+
$str_php_micros = '1412262083.045123';
506+
$this->assertEquals($str_php_micros, $obj_entity->when->format(\GDS\Mapper::DATETIME_FORMAT_UDOTU));
507+
$this->assertEquals('2014-10-02 15:01:23', $obj_entity->when->format('Y-m-d H:i:s'));
508+
$this->assertEquals('2015-11-03 16:02:24', $obj_entity->then->format('Y-m-d H:i:s'));
509+
}
510+
511+
/**
512+
* Confirm we correctly extract DateTime objects from REST responses
513+
*
514+
* @throws Exception
515+
*/
516+
public function testMapDatetimeFromGoogleInTimezone()
517+
{
518+
$str_existing_tz = date_default_timezone_get();
519+
date_default_timezone_set('America/Cayenne');
435520

436-
// public function testMapToGoogle()
437-
// {
438-
// $obj_mapper = new \GDS\Mapper\RESTv1();
439-
// $obj_gds_entity = new \GDS\Entity();
440-
// $obj_gds_entity->setKind('Person');
441-
// $obj_rest_entity = $obj_mapper->mapToGoogle($obj_gds_entity);
442-
// $this->assertEquals('expected', $obj_rest_entity->actual);
443-
// }
521+
$obj_schema = (new \GDS\Schema('Event'))->addDatetime('when');
522+
$obj_mapper = new \GDS\Mapper\RESTv1();
523+
$obj_mapper->setSchema($obj_schema);
524+
$obj_entity = $obj_mapper->mapOneFromResult($this->buildFakeResponse());
525+
$this->assertInstanceOf('\\DateTime', $obj_entity->when);
526+
$this->assertInstanceOf('\\DateTime', $obj_entity->then);
527+
$str_php_micros = '1412262083.045123';
528+
$this->assertEquals($str_php_micros, $obj_entity->when->format(\GDS\Mapper::DATETIME_FORMAT_UDOTU));
529+
$this->assertEquals('2014-10-02 12:01:23', $obj_entity->when->format('Y-m-d H:i:s'));
530+
$this->assertEquals('2015-11-03 13:02:24', $obj_entity->then->format('Y-m-d H:i:s'));
531+
$this->assertEquals('America/Cayenne', $obj_entity->when->getTimezone()->getName());
532+
$this->assertEquals('America/Cayenne', $obj_entity->then->getTimezone()->getName());
533+
534+
// Reset the timezone
535+
date_default_timezone_set($str_existing_tz);
536+
}
444537

445-
}
538+
/**
539+
* Build a fake REST response payload
540+
*
541+
* @return stdClass
542+
*/
543+
private function buildFakeResponse(): \stdClass
544+
{
545+
return (object)[
546+
'entity' => (object) [
547+
'key' => (object)[
548+
"partitionId" => (object)[
549+
"projectId" => 'test-project',
550+
"namespaceId" => 'test-namespace',
551+
],
552+
'path' => [
553+
(object)[
554+
"kind" => 'Event',
555+
"id" => '123456789',
556+
]
557+
]
558+
],
559+
'properties' => (object)[
560+
'when' => (object)[
561+
"timestampValue" => '2014-10-02T15:01:23.045123456Z',
562+
],
563+
'then' => (object)[
564+
"timestampValue" => '2015-11-03T16:02:24.055123456Z',
565+
],
566+
],
567+
]
568+
];
569+
}
570+
}

0 commit comments

Comments
 (0)