Skip to content

Commit

Permalink
Timezone support updates and additional tests
Browse files Browse the repository at this point in the history
  • Loading branch information
twgt committed Jul 7, 2021
1 parent 582add6 commit 2df12b5
Show file tree
Hide file tree
Showing 7 changed files with 418 additions and 28 deletions.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ If you need to continue running applications on that infrastructure, stick to ve
* [Getting Started](#getting-started) including installation with Composer and setup for GDS Emulator
* [Defining Your Model](#defining-your-model)
* [Creating Records](#creating-records)
* [Timezones and DateTime](#updated-timezone-support)
* [Geopoint Support](#geopoint)
* [Queries, GQL & The Default Query](#queries-gql--the-default-query)
* [Multi-tenant Applications & Data Namespaces](#multi-tenant-applications--data-namespaces)
Expand Down Expand Up @@ -143,6 +144,33 @@ Code: https://github.com/tomwalder/php-gds-demo
* Remove PHP 5 support
* Remove App Engine first-generation runtime support (inc direct Protocol Buffer API)

### Updated Timezone Support ###

In 5.1, timezone support has been improved for `DateTime` objects going in & out of Datastore.

#### How the data is stored
Datstore keeps the data recorded as UTC. When you browse data in the Google Cloud Console, they represent it in your locale.

#### Data coming out through PHP-GDS as Entities
You can now expect any `DateTime` object coming out of Datastore from PHP-GDS to have your current PHP default timezone applied. Example follows:

```php
date_default_timezone_set('America/New_York');

$obj_store = new GDS\Store('Book');
$obj_book = $obj_store->fetchOne();
echo $obj_book->published->format('c'); // 2004-02-12T15:19:21-05:00
echo $obj_book->published->getTimezone()->getName(); // America/New_York
```

#### Data going in - multi format support
If you pass in a `DateTime` object (or anything matching `DateTimeInterface`), we will respect the timezone set on it.

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:
https://www.php.net/manual/en/datetime.construct.php

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.

## Changes in Version 4 ##

* More consistent use of `DateTime` objects - now all result sets will use them instead of `Y-m-d H:i:s` strings
Expand Down
2 changes: 1 addition & 1 deletion src/GDS/Gateway/RESTv1.php
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ protected function configureObjectValueParamForQuery($obj_val, $mix_value)
/** @var Entity $mix_value */
$obj_val->keyValue = $this->applyPartition((object)['path' => $this->createMapper()->buildKeyPath($mix_value)]);
} elseif ($mix_value instanceof \DateTimeInterface) {
$obj_val->timestampValue = $mix_value->format(\GDS\Mapper\RESTv1::DATETIME_FORMAT);
$obj_val->timestampValue = $mix_value->format(\GDS\Mapper\RESTv1::DATETIME_FORMAT_ZULU);
} elseif (method_exists($mix_value, '__toString')) {
$obj_val->stringValue = $mix_value->__toString();
} else {
Expand Down
8 changes: 2 additions & 6 deletions src/GDS/Mapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,9 @@ abstract class Mapper
/**
* Datetime formats
*/
const DATETIME_FORMAT_UU = 'Uu';
const DATETIME_FORMAT_UDOTU = 'U.u';

/**
* Microseconds in a second
*/
const MICROSECONDS = 1000000;
const TZ_UTC = 'UTC';
const TZ_UTC_OFFSET = '+00:00';

/**
* Current Schema
Expand Down
10 changes: 8 additions & 2 deletions src/GDS/Mapper/GRPCv1.php
Original file line number Diff line number Diff line change
Expand Up @@ -343,13 +343,19 @@ private function configureGooglePropertyValue(array $arr_field_def, $mix_value)
/**
* Extract a datetime value
*
* Attempt to retain microsecond precision
*
* @param Value $obj_property
* @return mixed
*/
protected function extractDatetimeValue($obj_property)
{
// Attempt to retain microsecond precision
return $obj_property->getTimestampValue()->toDateTime();
$obj_dtm = $obj_property->getTimestampValue()->toDateTime();
$str_default_tz = date_default_timezone_get();
if (self::TZ_UTC === $str_default_tz || self::TZ_UTC_OFFSET === $str_default_tz) {
return $obj_dtm;
}
return $obj_dtm->setTimezone(new \DateTimeZone($str_default_tz));
}

/**
Expand Down
41 changes: 31 additions & 10 deletions src/GDS/Mapper/RESTv1.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class RESTv1 extends \GDS\Mapper
*
* A timestamp in RFC3339 UTC "Zulu" format, accurate to nanoseconds. Example: "2014-10-02T15:01:23.045123456Z".
*/
const DATETIME_FORMAT = 'Y-m-d\TH:i:s.u\Z';
const DATETIME_FORMAT_ZULU = 'Y-m-d\TH:i:s.u\Z';

/**
* Auto detect & extract a value
Expand Down Expand Up @@ -71,22 +71,40 @@ protected function extractAutoDetectValue($obj_property)
/**
* Extract a datetime value
*
* We will lose accuracy
* - past seconds in version 3.0
* - past microseconds (down from nanoseconds) in version 4.0
* Response values are ...
* A timestamp in RFC3339 UTC "Zulu" format, accurate to nanoseconds. Example: "2014-10-02T15:01:23.045123456Z".
*
* Construct as UTC, then apply default timezone before returning
*
* PHP cannot handle more that 6 d.p. (microseconds), so we parse out as best we can with preg_match()
*
* @param $obj_property
* @return mixed
*/
protected function extractDatetimeValue($obj_property)
{
return $this->buildLocalisedDateTimeObjectFromUTCString((string) $obj_property->timestampValue);
}

/**
* Build and return a DateTime, with the current timezone applied
*
* @param string $str_datetime
* @return \DateTime
* @throws \Exception
*/
public function buildLocalisedDateTimeObjectFromUTCString(string $str_datetime): \DateTime
{
$arr_matches = [];
if(preg_match('/(.{19})\.?(\d{0,6}).*Z/', $obj_property->timestampValue, $arr_matches) > 0) {
$obj_dtm = new \DateTime($arr_matches[1] . '.' . $arr_matches[2] . 'Z');
} else {
$obj_dtm = new \DateTime($obj_property->timestampValue);
if(preg_match('/(.{19})\.?(\d{0,6}).*Z/', $str_datetime, $arr_matches) > 0) {
$str_datetime = $arr_matches[1] . '.' . $arr_matches[2] . 'Z';
}
$str_default_tz = date_default_timezone_get();
if (self::TZ_UTC === $str_default_tz || self::TZ_UTC_OFFSET === $str_default_tz) {
new \DateTime($str_datetime);
}
return $obj_dtm;
return (new \DateTime($str_datetime, new \DateTimeZone(self::TZ_UTC)))
->setTimezone(new \DateTimeZone($str_default_tz));
}

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

case Schema::PROPERTY_DOUBLE:
Expand Down
143 changes: 134 additions & 9 deletions tests/RESTv1MapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,63 @@ public function testDateTimeMapToGoogle()

}

/**
* Test data going into Datastore has been correctly converted to UTC when operating in another TZ
*/
public function testDateTimeMapToGoogleWithTimezone()
{
// Let's use a timezone with no Daylight savings
// This is -03:00 hours
$str_existing_tz = date_default_timezone_get();
date_default_timezone_set('America/Cayenne');

$obj_schema = (new \GDS\Schema('Person'))->addDatetime('retirement');

$obj_mapper = new \GDS\Mapper\RESTv1();
$obj_mapper->setSchema($obj_schema);

$obj_gds_entity = new \GDS\Entity();
$obj_gds_entity->setSchema($obj_schema);
$obj_gds_entity->setKind('Person');

$obj_gds_entity->zoned = new DateTime('2021-02-04 08:30:00'); // takes on default timezone
$obj_gds_entity->dob = new DateTime('1979-02-05T08:30:00+09:00'); // timezone specified
$obj_gds_entity->exact = new DateTime('1979-02-05T08:30:00.12345678Z'); // UTC assumed
$obj_gds_entity->ts = new DateTime('@946684800'); // UTC assumed
$obj_gds_entity->retirement = '2050-01-01 09:00:00'; // takes on default timezone

$obj_rest_entity = $obj_mapper->mapToGoogle($obj_gds_entity);

$this->assertEquals(
'2021-02-04T11:30:00.000000Z',
$obj_rest_entity->properties->zoned->timestampValue,
'08:30 (-3) => 11:30 UTC'
);

// 1979-02-05T08:30:00+09:00 => previous day, 23:30
$this->assertEquals(
'1979-02-04T23:30:00.000000Z',
$obj_rest_entity->properties->dob->timestampValue,
'Previous day, 23:30'
);

// '1979-02-05T08:30:00.123457Z' 6 OR 7, depending on PHP version (>= 7.2, cuts not rounds)
$this->assertTrue(in_array($obj_rest_entity->properties->exact->timestampValue, [
'1979-02-05T08:30:00.123456Z', // PHP >= 7.2
'1979-02-05T08:30:00.123457Z', // PHP up to 7.1
]));

//
$this->assertEquals(
'2050-01-01T12:00:00.000000Z',
$obj_rest_entity->properties->retirement->timestampValue,
'-3 hours from Y-m-d H:i:s'
);

// Reset the timezone
date_default_timezone_set($str_existing_tz);
}

/**
* Ensure arrays of lat/lon pairs are supported for geopoints
*/
Expand Down Expand Up @@ -432,14 +489,82 @@ public function testAncestryFromArray()
$this->assertEquals('Child', $obj_path_last->kind);
}

/**
* Confirm we correctly extract DateTime objects from REST responses
*
* @throws Exception
*/
public function testMapDatetimeFromGoogle()
{
$obj_schema = (new \GDS\Schema('Event'))->addDatetime('when');
$obj_mapper = new \GDS\Mapper\RESTv1();
$obj_mapper->setSchema($obj_schema);
$obj_entity = $obj_mapper->mapOneFromResult($this->buildFakeResponse());
$this->assertInstanceOf('\\DateTime', $obj_entity->when);
$this->assertInstanceOf('\\DateTime', $obj_entity->then);
$str_php_micros = '1412262083.045123';
$this->assertEquals($str_php_micros, $obj_entity->when->format(\GDS\Mapper::DATETIME_FORMAT_UDOTU));
$this->assertEquals('2014-10-02 15:01:23', $obj_entity->when->format('Y-m-d H:i:s'));
$this->assertEquals('2015-11-03 16:02:24', $obj_entity->then->format('Y-m-d H:i:s'));
}

/**
* Confirm we correctly extract DateTime objects from REST responses
*
* @throws Exception
*/
public function testMapDatetimeFromGoogleInTimezone()
{
$str_existing_tz = date_default_timezone_get();
date_default_timezone_set('America/Cayenne');

// public function testMapToGoogle()
// {
// $obj_mapper = new \GDS\Mapper\RESTv1();
// $obj_gds_entity = new \GDS\Entity();
// $obj_gds_entity->setKind('Person');
// $obj_rest_entity = $obj_mapper->mapToGoogle($obj_gds_entity);
// $this->assertEquals('expected', $obj_rest_entity->actual);
// }
$obj_schema = (new \GDS\Schema('Event'))->addDatetime('when');
$obj_mapper = new \GDS\Mapper\RESTv1();
$obj_mapper->setSchema($obj_schema);
$obj_entity = $obj_mapper->mapOneFromResult($this->buildFakeResponse());
$this->assertInstanceOf('\\DateTime', $obj_entity->when);
$this->assertInstanceOf('\\DateTime', $obj_entity->then);
$str_php_micros = '1412262083.045123';
$this->assertEquals($str_php_micros, $obj_entity->when->format(\GDS\Mapper::DATETIME_FORMAT_UDOTU));
$this->assertEquals('2014-10-02 12:01:23', $obj_entity->when->format('Y-m-d H:i:s'));
$this->assertEquals('2015-11-03 13:02:24', $obj_entity->then->format('Y-m-d H:i:s'));
$this->assertEquals('America/Cayenne', $obj_entity->when->getTimezone()->getName());
$this->assertEquals('America/Cayenne', $obj_entity->then->getTimezone()->getName());

// Reset the timezone
date_default_timezone_set($str_existing_tz);
}

}
/**
* Build a fake REST response payload
*
* @return stdClass
*/
private function buildFakeResponse(): \stdClass
{
return (object)[
'entity' => (object) [
'key' => (object)[
"partitionId" => (object)[
"projectId" => 'test-project',
"namespaceId" => 'test-namespace',
],
'path' => [
(object)[
"kind" => 'Event',
"id" => '123456789',
]
]
],
'properties' => (object)[
'when' => (object)[
"timestampValue" => '2014-10-02T15:01:23.045123456Z',
],
'then' => (object)[
"timestampValue" => '2015-11-03T16:02:24.055123456Z',
],
],
]
];
}
}
Loading

0 comments on commit 2df12b5

Please sign in to comment.