├── public ├── _footer.php ├── README.md ├── _home.php ├── examples │ └── books_schemaless │ │ ├── delete_all.php │ │ ├── delete_one.php │ │ ├── create_one.php │ │ ├── create_many.php │ │ └── list.php ├── _header.php ├── _nav.php └── index.php ├── .gitignore ├── app.yaml ├── .travis.yml ├── tests ├── base │ ├── RESTv1GatewayBackoff.php │ ├── Book.php │ ├── Simple.php │ ├── RESTv1Test.php │ └── FakeGuzzleClient.php ├── bootstrap.php ├── CustomEntityClassTest.php ├── SchemaTest.php ├── GRPCv1MapperTest.php ├── EntityTest.php ├── GeopointTest.php ├── BackoffTest.php ├── TimeZoneTest.php └── RESTv1NamespaceTest.php ├── phpunit.xml ├── .gcloudignore ├── src └── GDS │ ├── Exception │ └── Contention.php │ ├── Property │ └── Geopoint.php │ ├── Entity.php │ ├── Mapper.php │ ├── Schema.php │ ├── Store.php │ ├── Gateway.php │ ├── Mapper │ ├── GRPCv1.php │ └── RESTv1.php │ └── Gateway │ ├── RESTv1.php │ └── GRPCv1.php ├── composer.json ├── LICENSE └── README.md /public/_footer.php: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | examples/scratch.php 3 | build/ 4 | index.yaml 5 | composer.lock 6 | .phpunit.result.cache -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | # Simple app.yaml for the PHP GDS demo app 2 | 3 | runtime: php74 4 | 5 | handlers: 6 | - url: .* 7 | script: auto -------------------------------------------------------------------------------- /public/README.md: -------------------------------------------------------------------------------- 1 | ## Demo App Engine Application 2 | 3 | This folder contains a very simple App Engine application that uses basic PHP-GDS features to demo core CRUS operations. 4 | 5 | Deploy with `gcloud app deploy`. -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | dist: trusty 4 | 5 | php: 6 | - 7.2 7 | - 7.3 8 | - 7.4 9 | 10 | before_script: 11 | - composer self-update 12 | - composer install --prefer-source 13 | 14 | script: 15 | - mkdir -p build/logs 16 | - php vendor/bin/phpunit 17 | 18 | after_script: 19 | - php vendor/bin/coveralls -v 20 | -------------------------------------------------------------------------------- /tests/base/RESTv1GatewayBackoff.php: -------------------------------------------------------------------------------- 1 | executeWithExponentialBackoff($fnc_main, $str_exception, $fnc_resolve_exception); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /public/_home.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Datastore Operations

5 |

To see basic Datastore operations in action, deploy this to an App Engine project with gcloud app deploy

6 |

Use the menu above to run the various operations.

7 |

The code can be seen in the /public/examples folder on GitHub

8 |
9 |
10 |
-------------------------------------------------------------------------------- /public/examples/books_schemaless/delete_all.php: -------------------------------------------------------------------------------- 1 | fetchAll(); 5 | 6 | if (!empty($arr_books)) { 7 | $obj_store->delete($arr_books); 8 | $int_books = count($arr_books); 9 | } else { 10 | $int_books = 0; 11 | } 12 | 13 | ?> 14 | 15 |
16 |
17 |

Delete Books

18 |
19 | Deleted books 20 |
21 |
22 |
-------------------------------------------------------------------------------- /public/examples/books_schemaless/delete_one.php: -------------------------------------------------------------------------------- 1 | fetchOne(); 5 | 6 | if ($obj_book instanceof \GDS\Entity) { 7 | $obj_store->delete($obj_book); 8 | } else { 9 | echo 'No books to delete'; 10 | return; 11 | } 12 | 13 | ?> 14 | 15 |
16 |
17 |

Delete Book

18 |
19 | Deleted Book with ID getKeyId(); ?> 20 |
21 |
22 |
-------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tests 5 | 6 | 7 | 8 | vendor/google/appengine-php-sdk 9 | 10 | 11 | 12 | 13 | 14 | 15 | ./src 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | 8 | // Time zone 9 | date_default_timezone_set('UTC'); 10 | 11 | // Autoloader 12 | require_once(dirname(__FILE__) . '/../vendor/autoload.php'); 13 | 14 | // Base Test Files 15 | require_once(dirname(__FILE__) . '/base/RESTv1GatewayBackoff.php'); 16 | require_once(dirname(__FILE__) . '/base/RESTv1Test.php'); 17 | require_once(dirname(__FILE__) . '/base/Simple.php'); 18 | require_once(dirname(__FILE__) . '/base/Book.php'); 19 | require_once(dirname(__FILE__) . '/base/FakeGuzzleClient.php'); 20 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud Platform 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | .phpunit.result.cache 16 | 17 | # PHP Composer dependencies: 18 | /vendor/ -------------------------------------------------------------------------------- /public/examples/books_schemaless/create_one.php: -------------------------------------------------------------------------------- 1 | title = 'Romeo and Juliet'; 8 | $obj_book->author = 'William Shakespeare'; 9 | $obj_book->isbn = '1840224339'; 10 | 11 | // Insert into the Datastore 12 | $obj_store->upsert($obj_book); 13 | 14 | ?> 15 | 16 |
17 |
18 |

Create Book

19 |
20 | Created Book with ID getKeyId(); ?> 21 |
22 |
23 |
-------------------------------------------------------------------------------- /public/_header.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | PHP GDS Demo 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/base/Book.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class Book extends \GDS\Entity 24 | { 25 | 26 | } -------------------------------------------------------------------------------- /src/GDS/Exception/Contention.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class Contention extends \Exception 25 | { 26 | 27 | } -------------------------------------------------------------------------------- /public/examples/books_schemaless/create_many.php: -------------------------------------------------------------------------------- 1 | createEntity([ 7 | 'title' => 'Romeo and Juliet', 8 | 'author' => 'William Shakespeare', 9 | 'isbn' => '1840224339' 10 | ]); 11 | $obj_midsummer = $obj_store->createEntity([ 12 | 'title' => "A Midsummer Night's Dream", 13 | 'author' => 'William Shakespeare', 14 | 'isbn' => '1853260304' 15 | ]); 16 | 17 | // Insert multiple into the Datastore 18 | $arr_books = [$obj_romeo, $obj_midsummer]; 19 | $obj_store->upsert($arr_books); 20 | 21 | ?> 22 | 23 |
24 |
25 |

Create Book

26 |
27 | 28 | Created Book with ID getKeyId(); ?>
29 | 30 |
31 |
32 |
-------------------------------------------------------------------------------- /tests/base/Simple.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class Simple 24 | { 25 | /** 26 | * Return a string 27 | * 28 | * @return string 29 | */ 30 | public function __toString() 31 | { 32 | return 'success!'; 33 | } 34 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tomwalder/php-gds", 3 | "type": "library", 4 | "description": "Google Cloud Datastore Library for PHP. Also Firestore in Datastore mode.", 5 | "keywords": ["google", "datastore", "appengine", "gae", "cloud datastore", "nosql", "firestore"], 6 | "homepage": "https://github.com/tomwalder/php-gds", 7 | "license": "Apache-2.0", 8 | "authors": [ 9 | { 10 | "name": "Tom Walder", 11 | "email": "twalder@gmail.com" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=7.0", 16 | "ext-bcmath" : "*", 17 | "google/auth": "^1.0" 18 | }, 19 | "require-dev": { 20 | "google/cloud-datastore": "^v1.12", 21 | "phpunit/phpunit": "~8", 22 | "satooshi/php-coveralls": "^v2.2" 23 | }, 24 | "suggest": { 25 | "google/cloud-datastore": "If you need to use the gRPC API & Gateway" 26 | }, 27 | "autoload": { 28 | "classmap": [ 29 | "src/" 30 | ], 31 | "psr-4": {"GDS\\": "src/GDS/"} 32 | }, 33 | "include-path": ["src/"], 34 | "config": { 35 | "platform": { 36 | "php": "7.2.0" 37 | }, 38 | "optimize-autoloader": true, 39 | "sort-packages": true 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /public/examples/books_schemaless/list.php: -------------------------------------------------------------------------------- 1 | fetchAll(); 5 | 6 | ?> 7 | 8 |
9 |
10 |

Book List

11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
TitleAuthorISBN
No books found
title); ?>author); ?>isbn); ?>
30 |
31 |
32 | Raw results array - print_r($arr_books); 33 |
34 |
35 |
36 |
-------------------------------------------------------------------------------- /public/_nav.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | [ 15 | 'name' => 'Schemaless', 16 | 'description' => 'Simple, schemaless entity ("Book") examples', 17 | 'actions' => [ 18 | 'list' => 'List Books', 19 | 'create_one' => 'Create one new Book', 20 | 'create_many' => 'Create several Books', 21 | 'delete_one' => 'Delete one Book', 22 | 'delete_all' => 'Delete all Books', 23 | ] 24 | ], 25 | // 'books_with_schema' => [ 26 | // 'name' => 'With a Schema', 27 | // 'description' => 'More entity examples, using a defined Schema ("Book")', 28 | // 'actions' => [], 29 | // ], 30 | ]; 31 | 32 | require_once __DIR__ . '/_nav.php'; 33 | $bol_action = false; 34 | $str_demo = $_GET['demo'] ?? null; 35 | if (!empty($str_demo) && isset($arr_examples[$str_demo])) { 36 | $str_action = $_GET['action'] ?? null; 37 | if (!empty($str_action) && isset($arr_examples[$str_demo]['actions'][$str_action])) { 38 | $str_action_file = implode('/', [__DIR__, 'examples', $str_demo, $str_action]) . '.php'; 39 | if (is_readable($str_action_file)) { 40 | $bol_action = true; 41 | require_once $str_action_file; 42 | } 43 | } 44 | } 45 | if (!$bol_action) { 46 | require_once __DIR__ . '/_home.php'; 47 | } 48 | require_once __DIR__ . '/_footer.php'; 49 | } catch (\Throwable $obj_thrown) { 50 | echo "Something went wrong: " . $obj_thrown->getMessage(); 51 | } 52 | 53 | -------------------------------------------------------------------------------- /tests/base/RESTv1Test.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | abstract class RESTv1Test extends \PHPUnit\Framework\TestCase 24 | { 25 | 26 | /** 27 | * Dataset 28 | */ 29 | const TEST_PROJECT = 'DatasetTest'; 30 | 31 | /** 32 | * @var string 33 | */ 34 | private $str_expected_url = null; 35 | 36 | /** 37 | * @var string 38 | */ 39 | private $arr_expected_payload = null; 40 | 41 | /** 42 | * Prepare and return a fake Guzzle HTTP client, so that we can test and simulate requests/responses 43 | * 44 | * @param $str_expected_url 45 | * @param null $arr_expected_payload 46 | * @param null $obj_response 47 | * @return FakeGuzzleClient 48 | */ 49 | protected function initTestHttpClient($str_expected_url, $arr_expected_payload = null, $obj_response = null) 50 | { 51 | $this->str_expected_url = $str_expected_url; 52 | $this->arr_expected_payload = $arr_expected_payload; 53 | return new FakeGuzzleClient($obj_response); 54 | } 55 | 56 | /** 57 | * Build and return a testable Gateway 58 | * 59 | * @param null $str_namespace 60 | * @return PHPUnit_Framework_MockObject_MockObject 61 | */ 62 | protected function initTestGateway($str_namespace = null) 63 | { 64 | return $this->getMockBuilder('\\GDS\\Gateway\\RESTv1')->setMethods(['initHttpClient'])->setConstructorArgs([self::TEST_PROJECT, $str_namespace])->getMock(); 65 | } 66 | 67 | /** 68 | * Validate URL and Payload 69 | * 70 | * @param FakeGuzzleClient $obj_http 71 | */ 72 | protected function validateHttpClient(\FakeGuzzleClient $obj_http) 73 | { 74 | $this->assertEquals($this->str_expected_url, $obj_http->getPostedUrl()); 75 | if(null !== $this->arr_expected_payload) { 76 | $this->assertEquals($this->arr_expected_payload, $obj_http->getPostedParams()); 77 | } 78 | } 79 | 80 | 81 | } -------------------------------------------------------------------------------- /tests/CustomEntityClassTest.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class CustomEntityClassTest extends \PHPUnit\Framework\TestCase 24 | { 25 | 26 | /** 27 | * Test setting a non-existent Entity class 28 | */ 29 | public function testSetMissingClass() 30 | { 31 | $this->expectException(InvalidArgumentException::class); 32 | $this->expectExceptionMessage('Cannot set missing Entity class: DoesNotExist'); 33 | $obj_gateway = new \GDS\Gateway\RESTv1('Dataset'); 34 | $obj_store = new \GDS\Store('Book', $obj_gateway); 35 | $obj_store->setEntityClass('DoesNotExist'); 36 | } 37 | 38 | /** 39 | * Test setting a non-Entity class 40 | */ 41 | public function testSetInvalidClass() 42 | { 43 | $this->expectException(InvalidArgumentException::class); 44 | $this->expectExceptionMessage('Cannot set an Entity class that does not extend "GDS\Entity": Simple'); 45 | $obj_gateway = new \GDS\Gateway\RESTv1('Dataset'); 46 | $obj_store = new \GDS\Store('Book', $obj_gateway); 47 | $obj_store->setEntityClass('Simple'); 48 | } 49 | 50 | /** 51 | * Set the Book custom entity class 52 | */ 53 | public function testSetClass() 54 | { 55 | $obj_gateway = new \GDS\Gateway\RESTv1('Dataset'); 56 | $obj_store = new \GDS\Store('Book', $obj_gateway); 57 | $obj_store2 = $obj_store->setEntityClass('Book'); 58 | $this->assertSame($obj_store, $obj_store2); 59 | } 60 | 61 | /** 62 | * Set the Book custom entity class 63 | */ 64 | public function testCreateEntity() 65 | { 66 | $obj_gateway = new \GDS\Gateway\RESTv1('Dataset'); 67 | $obj_store = new \GDS\Store('Book', $obj_gateway); 68 | $obj_store->setEntityClass('Book'); 69 | $obj_book = $obj_store->createEntity(['title' => 'Discworld']); 70 | $this->assertInstanceOf('\\Book', $obj_book); 71 | } 72 | 73 | // @todo test with Schema 74 | 75 | } -------------------------------------------------------------------------------- /tests/SchemaTest.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class SchemaTest extends \PHPUnit\Framework\TestCase { 24 | 25 | /** 26 | * Set up a schema with all data types 27 | */ 28 | public function testSchema() 29 | { 30 | $obj_schema = (new \GDS\Schema('Person')) 31 | ->addString('name') 32 | ->addInteger('age') 33 | ->addDatetime('dob') 34 | ->addBoolean('single') 35 | ->addFloat('weight') 36 | ->addStringList('nicknames') 37 | ->addProperty('surname', \GDS\Schema::PROPERTY_STRING) 38 | ->addInteger('friends', FALSE) 39 | ->addGeopoint('location') 40 | ; 41 | $this->assertEquals($obj_schema->getKind(), 'Person'); 42 | $this->assertEquals($obj_schema->getProperties(), [ 43 | 'name' => [ 44 | 'type' => \GDS\Schema::PROPERTY_STRING, 45 | 'index' => TRUE 46 | ], 47 | 'age' => [ 48 | 'type' => \GDS\Schema::PROPERTY_INTEGER, 49 | 'index' => TRUE 50 | ], 51 | 'dob' => [ 52 | 'type' => \GDS\Schema::PROPERTY_DATETIME, 53 | 'index' => TRUE 54 | ], 55 | 'single' => [ 56 | 'type' => \GDS\Schema::PROPERTY_BOOLEAN, 57 | 'index' => TRUE 58 | ], 59 | 'weight' => [ 60 | 'type' => \GDS\Schema::PROPERTY_FLOAT, 61 | 'index' => TRUE 62 | ], 63 | 'nicknames' => [ 64 | 'type' => \GDS\Schema::PROPERTY_STRING_LIST, 65 | 'index' => TRUE 66 | ], 67 | 'surname' => [ 68 | 'type' => \GDS\Schema::PROPERTY_STRING, 69 | 'index' => TRUE 70 | ], 71 | 'friends' => [ 72 | 'type' => \GDS\Schema::PROPERTY_INTEGER, 73 | 'index' => FALSE 74 | ], 75 | 'location' => [ 76 | 'type' => \GDS\Schema::PROPERTY_GEOPOINT, 77 | 'index' => TRUE 78 | ] 79 | ]); 80 | } 81 | 82 | } -------------------------------------------------------------------------------- /tests/GRPCv1MapperTest.php: -------------------------------------------------------------------------------- 1 | addString('name') 12 | ->addInteger('age') 13 | ->addFloat('weight') 14 | ->addGeopoint('location') 15 | ->addDatetime('dob'); 16 | 17 | $obj_mapper = new \GDS\Mapper\GRPCv1(); 18 | $obj_mapper 19 | ->setSchema($obj_schema); 20 | 21 | 22 | $obj_gds_entity = new \GDS\Entity(); 23 | $obj_gds_entity->setSchema($obj_schema); 24 | $obj_gds_entity->setKind('Person'); 25 | 26 | $obj_gds_entity->name = 'Dave'; 27 | $obj_gds_entity->age = 21; 28 | $obj_gds_entity->weight = 92.6; 29 | $obj_gds_entity->location = new \GDS\Property\Geopoint(1.2, 3.4); 30 | $obj_gds_entity->dob = new DateTime('1979-02-05 08:30:00'); 31 | 32 | $obj_grpc_entity = new GRPC_Entity(); 33 | 34 | $obj_mapper->mapToGoogle($obj_gds_entity, $obj_grpc_entity); 35 | 36 | 37 | $obj_properties = json_decode($obj_grpc_entity->serializeToJsonString())->properties; 38 | 39 | $this->assertTrue(property_exists($obj_properties->name, 'stringValue')); 40 | $this->assertTrue(property_exists($obj_properties->age, 'integerValue')); 41 | $this->assertTrue(property_exists($obj_properties->weight, 'doubleValue')); 42 | $this->assertTrue(property_exists($obj_properties->location, 'geoPointValue')); 43 | $this->assertTrue(property_exists($obj_properties->dob, 'timestampValue')); 44 | } 45 | 46 | public function testNullValuesMapToGoogleEntity() 47 | { 48 | $obj_schema = (new \GDS\Schema('Person')) 49 | ->addString('name') 50 | ->addInteger('age') 51 | ->addFloat('weight') 52 | ->addGeopoint('location') 53 | ->addDatetime('dob'); 54 | 55 | $obj_mapper = new \GDS\Mapper\GRPCv1(); 56 | $obj_mapper 57 | ->setSchema($obj_schema); 58 | 59 | 60 | $obj_gds_entity = new \GDS\Entity(); 61 | $obj_gds_entity->setSchema($obj_schema); 62 | $obj_gds_entity->setKind('Person'); 63 | 64 | $obj_gds_entity->name = null; 65 | $obj_gds_entity->age = null; 66 | $obj_gds_entity->weight = null; 67 | $obj_gds_entity->location = null; 68 | $obj_gds_entity->dob = null; 69 | 70 | $obj_grpc_entity = new GRPC_Entity(); 71 | 72 | $obj_mapper->mapToGoogle($obj_gds_entity, $obj_grpc_entity); 73 | 74 | 75 | $obj_properties = json_decode($obj_grpc_entity->serializeToJsonString())->properties; 76 | 77 | $this->assertTrue(property_exists($obj_properties->name, 'nullValue')); 78 | $this->assertTrue(property_exists($obj_properties->age, 'nullValue')); 79 | $this->assertTrue(property_exists($obj_properties->weight, 'nullValue')); 80 | $this->assertTrue(property_exists($obj_properties->location, 'nullValue')); 81 | $this->assertTrue(property_exists($obj_properties->dob, 'nullValue')); 82 | } 83 | } -------------------------------------------------------------------------------- /src/GDS/Property/Geopoint.php: -------------------------------------------------------------------------------- 1 | 23 | * @package GDS 24 | */ 25 | class Geopoint implements \ArrayAccess 26 | { 27 | 28 | private $flt_lat = 0.0; 29 | 30 | private $flt_lon = 0.0; 31 | 32 | public function __construct($latitude = 0.0, $longitude = 0.0) 33 | { 34 | $this->flt_lat = (float)$latitude; 35 | $this->flt_lon = (float)$longitude; 36 | } 37 | 38 | public function getLatitude() 39 | { 40 | return $this->flt_lat; 41 | } 42 | 43 | public function getLongitude() 44 | { 45 | return $this->flt_lon; 46 | } 47 | 48 | public function setLatitude($latitude) 49 | { 50 | $this->flt_lat = (float)$latitude; 51 | return $this; 52 | } 53 | 54 | public function setLongitude($longitude) 55 | { 56 | $this->flt_lon = (float)$longitude; 57 | return $this; 58 | } 59 | 60 | /** 61 | * ArrayAccess 62 | * 63 | * @param mixed $offset 64 | * @return bool 65 | */ 66 | public function offsetExists($offset) 67 | { 68 | return (0 === $offset || 1 === $offset); 69 | } 70 | 71 | /** 72 | * ArrayAccess 73 | * 74 | * @param mixed $offset 75 | * @return float 76 | */ 77 | public function offsetGet($offset) 78 | { 79 | if(0 === $offset) { 80 | return $this->getLatitude(); 81 | } 82 | if(1 === $offset) { 83 | return $this->getLongitude(); 84 | } 85 | throw new \UnexpectedValueException("Cannot get Geopoint data with offset [{$offset}]"); 86 | } 87 | 88 | /** 89 | * ArrayAccess 90 | * 91 | * @param mixed $offset 92 | * @param mixed $value 93 | * @return $this|Geopoint 94 | */ 95 | public function offsetSet($offset, $value) 96 | { 97 | if(0 === $offset) { 98 | $this->setLatitude($value); 99 | return; 100 | } 101 | if(1 === $offset) { 102 | $this->setLongitude($value); 103 | return; 104 | } 105 | throw new \UnexpectedValueException("Cannot set Geopoint data with offset [{$offset}]"); 106 | } 107 | 108 | /** 109 | * ArrayAccess 110 | * 111 | * @param mixed $offset 112 | */ 113 | public function offsetUnset($offset) 114 | { 115 | if(0 === $offset) { 116 | $this->setLatitude(0.0); 117 | return; 118 | } 119 | if(1 === $offset) { 120 | $this->setLongitude(0.0); 121 | return; 122 | } 123 | throw new \UnexpectedValueException("Cannot unset Geopoint data with offset [{$offset}]"); 124 | } 125 | } -------------------------------------------------------------------------------- /tests/EntityTest.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class EntityTest extends \PHPUnit\Framework\TestCase 24 | { 25 | 26 | /** 27 | * Check our Kind and KeyId/KeyName setters, getters 28 | */ 29 | public function testBasics() 30 | { 31 | $obj_entity1 = new GDS\Entity(); 32 | $obj_entity1->setKind('Testing')->setKeyId(123456); 33 | $this->assertEquals($obj_entity1->getKind(), 'Testing'); 34 | $this->assertEquals($obj_entity1->getKeyId(), 123456); 35 | 36 | $obj_entity2 = new GDS\Entity(); 37 | $obj_entity2->setKind('Testing')->setKeyName('testing'); 38 | $this->assertEquals($obj_entity2->getKind(), 'Testing'); 39 | $this->assertEquals($obj_entity2->getKeyName(), 'testing'); 40 | } 41 | 42 | /** 43 | * Ensure a Schema when applied sets the Kind 44 | */ 45 | public function testBasicSchema() 46 | { 47 | $obj_schema = new \GDS\Schema('SomeKindOfTest'); 48 | $obj_entity = new GDS\Entity(); 49 | $obj_entity->setSchema($obj_schema); 50 | $this->assertEquals($obj_entity->getKind(), 'SomeKindOfTest'); 51 | $this->assertEquals($obj_entity->getSchema(), $obj_schema); 52 | } 53 | 54 | /** 55 | * Ensure parameters can be set, detected and retrieved 56 | */ 57 | public function testIsset() 58 | { 59 | $obj_entity = new GDS\Entity(); 60 | $obj_entity->setSchema(new \GDS\Schema('SomeKindOfTest')); 61 | $obj_entity->test_property = 'Has value'; 62 | $this->assertTrue(isset($obj_entity->test_property)); 63 | $this->assertFalse(isset($obj_entity->another_property)); 64 | $this->assertEquals($obj_entity->test_property, 'Has value'); 65 | $this->assertEquals($obj_entity->another_property, null); 66 | } 67 | 68 | /** 69 | * Ensure parameters can be retrieved en masse 70 | */ 71 | public function testGetData() 72 | { 73 | $obj_entity = new GDS\Entity(); 74 | $obj_entity->setSchema(new \GDS\Schema('SomeKindOfTest')); 75 | $obj_entity->test_property = 'Has value'; 76 | $obj_entity->another_property = 'Another value'; 77 | $this->assertEquals($obj_entity->getData(), [ 78 | 'test_property' => 'Has value', 79 | 'another_property' => 'Another value' 80 | ]); 81 | } 82 | 83 | /** 84 | * Validate getters and setters for Ancestry 85 | */ 86 | public function testAncestry() 87 | { 88 | $obj_entity1 = new GDS\Entity(); 89 | $obj_entity1->setKind('Testing')->setKeyId(123456); 90 | 91 | $obj_entity2 = new GDS\Entity(); 92 | $obj_entity2->setKind('Testing')->setKeyName('testing'); 93 | $obj_entity2->setAncestry($obj_entity1); 94 | 95 | $this->assertEquals($obj_entity2->getAncestry(), $obj_entity1); 96 | } 97 | } -------------------------------------------------------------------------------- /tests/GeopointTest.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class GeopointTest extends \PHPUnit\Framework\TestCase 24 | { 25 | 26 | /** 27 | * 28 | */ 29 | public function testConstruct() 30 | { 31 | $obj_gp = new \GDS\Property\Geopoint(1.2, 3.4); 32 | $this->assertEquals(1.2, $obj_gp->getLatitude()); 33 | $this->assertEquals(3.4, $obj_gp->getLongitude()); 34 | } 35 | 36 | /** 37 | * 38 | */ 39 | public function testEmpty() 40 | { 41 | $obj_gp = new \GDS\Property\Geopoint(); 42 | $this->assertEquals(0.0, $obj_gp->getLatitude()); 43 | $this->assertEquals(0.0, $obj_gp->getLongitude()); 44 | } 45 | 46 | /** 47 | * 48 | */ 49 | public function testSetters() 50 | { 51 | $obj_gp = new \GDS\Property\Geopoint(); 52 | $obj_gp->setLatitude(5.6); 53 | $obj_gp->setLongitude(7.8); 54 | $this->assertEquals(5.6, $obj_gp->getLatitude()); 55 | $this->assertEquals(7.8, $obj_gp->getLongitude()); 56 | } 57 | 58 | /** 59 | * 60 | */ 61 | public function testArrayAccessRead() 62 | { 63 | $obj_gp = new \GDS\Property\Geopoint(1.2, 3.4); 64 | $this->assertEquals(1.2, $obj_gp[0]); 65 | $this->assertEquals(3.4, $obj_gp[1]); 66 | } 67 | 68 | /** 69 | * 70 | */ 71 | public function testArrayAccessWrite() 72 | { 73 | $obj_gp = new \GDS\Property\Geopoint(); 74 | $obj_gp[0] = 2.1; 75 | $obj_gp[1] = 3.4; 76 | $this->assertEquals(2.1, $obj_gp->getLatitude()); 77 | $this->assertEquals(3.4, $obj_gp->getLongitude()); 78 | } 79 | 80 | public function testIsset() 81 | { 82 | $obj_gp = new \GDS\Property\Geopoint(); 83 | $this->assertTrue(isset($obj_gp[0])); 84 | $this->assertTrue(isset($obj_gp[1])); 85 | $this->assertFalse(isset($obj_gp[2])); 86 | } 87 | 88 | public function testUnset() 89 | { 90 | $obj_gp = new \GDS\Property\Geopoint(1.2, 3.4); 91 | $this->assertEquals(1.2, $obj_gp->getLatitude()); 92 | $this->assertEquals(3.4, $obj_gp->getLongitude()); 93 | unset($obj_gp[0]); 94 | $this->assertEquals(0.0, $obj_gp->getLatitude()); 95 | $this->assertEquals(3.4, $obj_gp->getLongitude()); 96 | unset($obj_gp[1]); 97 | $this->assertEquals(0.0, $obj_gp->getLatitude()); 98 | $this->assertEquals(0.0, $obj_gp->getLongitude()); 99 | } 100 | 101 | public function testFailSet() 102 | { 103 | $obj_gp = new \GDS\Property\Geopoint(); 104 | $this->expectException('UnexpectedValueException'); 105 | $obj_gp[2] = 1.21; 106 | } 107 | 108 | public function testFailGet() 109 | { 110 | $obj_gp = new \GDS\Property\Geopoint(); 111 | $this->expectException('UnexpectedValueException'); 112 | $int_tmp = $obj_gp[2]; 113 | } 114 | 115 | } -------------------------------------------------------------------------------- /tests/base/FakeGuzzleClient.php: -------------------------------------------------------------------------------- 1 | obj_fake_response = $obj_response; 15 | } 16 | 17 | /** 18 | * Pretend to do a POST request 19 | * 20 | * @param $str_url 21 | * @param array $arr_params 22 | * @return \GuzzleHttp\Psr7\Response 23 | */ 24 | public function post($str_url, array $arr_params) 25 | { 26 | // echo $str_url, '::', print_r($arr_params, true), PHP_EOL; 27 | $this->str_url = $str_url; 28 | $this->arr_params = $arr_params; 29 | 30 | $obj_response = new \GuzzleHttp\Psr7\Response(); 31 | return $obj_response->withBody(\GuzzleHttp\Psr7\stream_for(json_encode($this->obj_fake_response))); 32 | 33 | } 34 | 35 | public function getPostedUrl() 36 | { 37 | return $this->str_url; 38 | } 39 | 40 | public function getPostedParams() 41 | { 42 | return $this->arr_params; 43 | } 44 | 45 | /** 46 | * Send an HTTP request. 47 | * 48 | * @param \Psr\Http\Message\RequestInterface $request Request to send 49 | * @param array $options Request options to apply to the given 50 | * request and to the transfer. 51 | * 52 | * @return \Psr\Http\Message\ResponseInterface 53 | * @throws \GuzzleHttp\Exception\GuzzleException 54 | */ 55 | public function send(\Psr\Http\Message\RequestInterface $request, array $options = []) 56 | { 57 | // TODO: Implement send() method. 58 | } 59 | 60 | /** 61 | * Asynchronously send an HTTP request. 62 | * 63 | * @param \Psr\Http\Message\RequestInterface $request Request to send 64 | * @param array $options Request options to apply to the given 65 | * request and to the transfer. 66 | * 67 | * @return \GuzzleHttp\Promise\PromiseInterface 68 | */ 69 | public function sendAsync(\Psr\Http\Message\RequestInterface $request, array $options = []) 70 | { 71 | // TODO: Implement sendAsync() method. 72 | } 73 | 74 | /** 75 | * Create and send an HTTP request. 76 | * 77 | * Use an absolute path to override the base path of the client, or a 78 | * relative path to append to the base path of the client. The URL can 79 | * contain the query string as well. 80 | * 81 | * @param string $method HTTP method. 82 | * @param string|\Psr\Http\Message\UriInterface $uri URI object or string. 83 | * @param array $options Request options to apply. 84 | * 85 | * @return \Psr\Http\Message\ResponseInterface 86 | * @throws \GuzzleHttp\Exception\GuzzleException 87 | */ 88 | public function request($method, $uri, array $options = []) 89 | { 90 | // TODO: Implement request() method. 91 | } 92 | 93 | /** 94 | * Create and send an asynchronous HTTP request. 95 | * 96 | * Use an absolute path to override the base path of the client, or a 97 | * relative path to append to the base path of the client. The URL can 98 | * contain the query string as well. Use an array to provide a URL 99 | * template and additional variables to use in the URL template expansion. 100 | * 101 | * @param string $method HTTP method 102 | * @param string|\Psr\Http\Message\UriInterface $uri URI object or string. 103 | * @param array $options Request options to apply. 104 | * 105 | * @return \GuzzleHttp\Promise\PromiseInterface 106 | */ 107 | public function requestAsync($method, $uri, array $options = []) 108 | { 109 | // TODO: Implement requestAsync() method. 110 | } 111 | 112 | /** 113 | * Get a client configuration option. 114 | * 115 | * These options include default request options of the client, a "handler" 116 | * (if utilized by the concrete client), and a "base_uri" if utilized by 117 | * the concrete client. 118 | * 119 | * @param string|null $option The config option to retrieve. 120 | * 121 | * @return mixed 122 | */ 123 | public function getConfig($option = null) 124 | { 125 | // TODO: Implement getConfig() method. 126 | } 127 | 128 | } -------------------------------------------------------------------------------- /src/GDS/Entity.php: -------------------------------------------------------------------------------- 1 | 25 | * @package GDS 26 | */ 27 | class Entity 28 | { 29 | 30 | /** 31 | * Datastore entity Kind 32 | * 33 | * @var string|null 34 | */ 35 | private $str_kind = null; 36 | 37 | /** 38 | * GDS record Key ID 39 | * 40 | * @var string 41 | */ 42 | private $str_key_id = null; 43 | 44 | /** 45 | * GDS record Key Name 46 | * 47 | * @var string 48 | */ 49 | private $str_key_name = null; 50 | 51 | /** 52 | * Entity ancestors 53 | * 54 | * @var null|array|Entity 55 | */ 56 | private $mix_ancestry = null; 57 | 58 | /** 59 | * Field Data 60 | * 61 | * @var array 62 | */ 63 | private $arr_data = []; 64 | 65 | /** 66 | * The Schema for the Entity, if known. 67 | * 68 | * @var Schema|null 69 | */ 70 | private $obj_schema = null; 71 | 72 | /** 73 | * Get the Entity Kind 74 | * 75 | * @return null 76 | */ 77 | public function getKind() 78 | { 79 | return $this->str_kind; 80 | } 81 | 82 | /** 83 | * Get the key ID 84 | * 85 | * @return string 86 | */ 87 | public function getKeyId() 88 | { 89 | return $this->str_key_id; 90 | } 91 | 92 | /** 93 | * Get the key name 94 | * 95 | * @return string 96 | */ 97 | public function getKeyName() 98 | { 99 | return $this->str_key_name; 100 | } 101 | 102 | /** 103 | * @param $str_kind 104 | * @return $this 105 | */ 106 | public function setKind($str_kind) 107 | { 108 | $this->str_kind = $str_kind; 109 | return $this; 110 | } 111 | 112 | /** 113 | * Set the key ID 114 | * 115 | * @param $str_key_id 116 | * @return $this 117 | */ 118 | public function setKeyId($str_key_id) 119 | { 120 | $this->str_key_id = $str_key_id; 121 | return $this; 122 | } 123 | 124 | /** 125 | * Set the key name 126 | * 127 | * @param $str_key_name 128 | * @return $this 129 | */ 130 | public function setKeyName($str_key_name) 131 | { 132 | $this->str_key_name = $str_key_name; 133 | return $this; 134 | } 135 | 136 | /** 137 | * Magic setter.. sorry 138 | * 139 | * @param $str_key 140 | * @param $mix_value 141 | */ 142 | public function __set($str_key, $mix_value) 143 | { 144 | $this->arr_data[$str_key] = $mix_value; 145 | } 146 | 147 | /** 148 | * Magic getter.. sorry 149 | * 150 | * @param $str_key 151 | * @return null 152 | */ 153 | public function __get($str_key) 154 | { 155 | if(isset($this->arr_data[$str_key])) { 156 | return $this->arr_data[$str_key]; 157 | } 158 | return null; 159 | } 160 | 161 | /** 162 | * Is a data value set? 163 | * 164 | * @param $str_key 165 | * @return bool 166 | */ 167 | public function __isset($str_key) 168 | { 169 | return isset($this->arr_data[$str_key]); 170 | } 171 | 172 | /** 173 | * Get the entire data array 174 | * 175 | * @return array 176 | */ 177 | public function getData() 178 | { 179 | return $this->arr_data; 180 | } 181 | 182 | /** 183 | * Set the Entity's ancestry. This either an array of paths OR another Entity 184 | * 185 | * @param $mix_path 186 | * @return $this 187 | */ 188 | public function setAncestry($mix_path) 189 | { 190 | $this->mix_ancestry = $mix_path; 191 | return $this; 192 | } 193 | 194 | /** 195 | * Get the ancestry of the entity 196 | * 197 | * @return null|array 198 | */ 199 | public function getAncestry() 200 | { 201 | return $this->mix_ancestry; 202 | } 203 | 204 | /** 205 | * The Schema for the Entity, if known. 206 | * 207 | * @return Schema|null 208 | */ 209 | public function getSchema() 210 | { 211 | return $this->obj_schema; 212 | } 213 | 214 | /** 215 | * Set the Schema for the Entity 216 | * 217 | * @param Schema $obj_schema 218 | * @return $this 219 | */ 220 | public function setSchema(Schema $obj_schema) 221 | { 222 | $this->obj_schema = $obj_schema; 223 | $this->setKind($obj_schema->getKind()); 224 | return $this; 225 | } 226 | 227 | } -------------------------------------------------------------------------------- /tests/BackoffTest.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class BackoffTest extends \PHPUnit\Framework\TestCase 24 | { 25 | public function testOnceAndReturn() 26 | { 27 | \GDS\Gateway::exponentialBackoff(true); 28 | $shouldBeCalled = $this->getMockBuilder(\stdClass::class) 29 | ->addMethods(['__invoke']) 30 | ->getMock(); 31 | $shouldBeCalled->expects($this->once()) 32 | ->method('__invoke') 33 | ->willReturn(87); 34 | $int_result = $this->buildTestGateway()->runExecuteWithExponentialBackoff($shouldBeCalled); 35 | $this->assertEquals(87, $int_result); 36 | } 37 | 38 | public function testBackoffCount() 39 | { 40 | \GDS\Gateway::exponentialBackoff(true); 41 | $shouldBeCalled = $this->getMockBuilder(\stdClass::class) 42 | ->addMethods(['__invoke']) 43 | ->getMock(); 44 | $shouldBeCalled->expects($this->exactly(\GDS\Gateway::RETRY_MAX_ATTEMPTS)) 45 | ->method('__invoke') 46 | ->willThrowException(new \RuntimeException('Test Exception', 503)); 47 | $this->expectException(\RuntimeException::class); 48 | $this->buildTestGateway()->runExecuteWithExponentialBackoff($shouldBeCalled); 49 | } 50 | 51 | public function testBackoffCountDisabled() 52 | { 53 | \GDS\Gateway::exponentialBackoff(false); 54 | $shouldBeCalled = $this->getMockBuilder(\stdClass::class) 55 | ->addMethods(['__invoke']) 56 | ->getMock(); 57 | $shouldBeCalled->expects($this->once()) 58 | ->method('__invoke') 59 | ->willThrowException(new \RuntimeException('Not retried', 503)); 60 | $this->expectException(\RuntimeException::class); 61 | $this->expectExceptionMessage('Not retried'); 62 | $this->expectExceptionCode(503); 63 | $this->buildTestGateway()->runExecuteWithExponentialBackoff($shouldBeCalled); 64 | } 65 | 66 | public function testPartialBackoff() { 67 | \GDS\Gateway::exponentialBackoff(true); 68 | $int_calls = 0; 69 | $shouldBeCalled = function () use (&$int_calls) { 70 | $int_calls++; 71 | if ($int_calls < 4) { 72 | throw new \RuntimeException('Always caught', 503); 73 | } 74 | return 42; 75 | }; 76 | $int_result = $this->buildTestGateway()->runExecuteWithExponentialBackoff($shouldBeCalled); 77 | $this->assertEquals(42, $int_result); 78 | $this->assertEquals(4, $int_calls); 79 | } 80 | 81 | 82 | public function testIgnoredExceptionClass() 83 | { 84 | \GDS\Gateway::exponentialBackoff(true); 85 | $shouldBeCalled = $this->getMockBuilder(\stdClass::class) 86 | ->addMethods(['__invoke']) 87 | ->getMock(); 88 | $shouldBeCalled->expects($this->once()) 89 | ->method('__invoke') 90 | ->willThrowException(new \LogicException('Ignored', 503)); 91 | $this->expectException(\LogicException::class); 92 | $this->expectExceptionMessage('Ignored'); 93 | $this->expectExceptionCode(503); 94 | $this->buildTestGateway()->runExecuteWithExponentialBackoff( 95 | $shouldBeCalled, 96 | \RuntimeException::class 97 | ); 98 | } 99 | 100 | public function testIgnoredExceptionCode() 101 | { 102 | \GDS\Gateway::exponentialBackoff(true); 103 | $shouldBeCalled = $this->getMockBuilder(\stdClass::class) 104 | ->addMethods(['__invoke']) 105 | ->getMock(); 106 | $shouldBeCalled->expects($this->once()) 107 | ->method('__invoke') 108 | ->willThrowException(new \RuntimeException('Non-retry code', 42)); 109 | $this->expectException(\RuntimeException::class); 110 | $this->expectExceptionMessage('Non-retry code'); 111 | $this->expectExceptionCode(42); 112 | $this->buildTestGateway()->runExecuteWithExponentialBackoff($shouldBeCalled); 113 | } 114 | 115 | public function testRetryOnce() 116 | { 117 | \GDS\Gateway::exponentialBackoff(true); 118 | $int_calls = 0; 119 | $shouldBeCalled = function () use (&$int_calls) { 120 | $int_calls++; 121 | throw new \RuntimeException('Once', 500); 122 | }; 123 | $this->expectException(\RuntimeException::class); 124 | $this->expectExceptionMessage('Once'); 125 | $this->expectExceptionCode(500); 126 | $this->buildTestGateway()->runExecuteWithExponentialBackoff($shouldBeCalled); 127 | $this->assertEquals(2, $int_calls); 128 | } 129 | 130 | private function buildTestGateway(): \RESTv1GatewayBackoff 131 | { 132 | return new RESTv1GatewayBackoff('dataset-id', 'my-app'); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/GDS/Mapper.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | abstract class Mapper 26 | { 27 | 28 | /** 29 | * Datetime formats 30 | */ 31 | const DATETIME_FORMAT_UDOTU = 'U.u'; 32 | const TZ_UTC = 'UTC'; 33 | const TZ_UTC_OFFSET = '+00:00'; 34 | 35 | /** 36 | * Current Schema 37 | * 38 | * @var Schema 39 | */ 40 | protected $obj_schema = null; 41 | 42 | /** 43 | * Set the schema 44 | * 45 | * @param Schema $obj_schema 46 | * @return $this 47 | */ 48 | public function setSchema(Schema $obj_schema) 49 | { 50 | $this->obj_schema = $obj_schema; 51 | return $this; 52 | } 53 | 54 | /** 55 | * Dynamically determine type for a value 56 | * 57 | * @param $mix_value 58 | * @return array 59 | */ 60 | protected function determineDynamicType($mix_value) 61 | { 62 | switch(gettype($mix_value)) { 63 | case 'boolean': 64 | $int_dynamic_type = Schema::PROPERTY_BOOLEAN; 65 | break; 66 | 67 | case 'integer': 68 | $int_dynamic_type = Schema::PROPERTY_INTEGER; 69 | break; 70 | 71 | case 'double': 72 | $int_dynamic_type = Schema::PROPERTY_DOUBLE; 73 | break; 74 | 75 | case 'string': 76 | $int_dynamic_type = Schema::PROPERTY_STRING; 77 | break; 78 | 79 | case 'array': 80 | $int_dynamic_type = Schema::PROPERTY_STRING_LIST; 81 | break; 82 | 83 | case 'object': 84 | if($mix_value instanceof \DateTimeInterface) { 85 | $int_dynamic_type = Schema::PROPERTY_DATETIME; 86 | break; 87 | } 88 | if($mix_value instanceof Geopoint) { 89 | $int_dynamic_type = Schema::PROPERTY_GEOPOINT; 90 | break; 91 | } 92 | $int_dynamic_type = Schema::PROPERTY_STRING; 93 | if(method_exists($mix_value, '__toString')) { 94 | $mix_value = $mix_value->__toString(); 95 | } else { 96 | $mix_value = null; 97 | } 98 | break; 99 | 100 | case 'resource': 101 | case 'null': 102 | case 'unknown type': 103 | default: 104 | $int_dynamic_type = Schema::PROPERTY_STRING; 105 | $mix_value = null; 106 | } 107 | return [ 108 | 'type' => $int_dynamic_type, 109 | 'value' => $mix_value 110 | ]; 111 | } 112 | 113 | /** 114 | * Map 1-many results out of the Raw response data array 115 | * 116 | * @param array $arr_results 117 | * @return Entity[]|null 118 | */ 119 | public function mapFromResults(array $arr_results) 120 | { 121 | $arr_entities = []; 122 | foreach ($arr_results as $obj_result) { 123 | $arr_entities[] = $this->mapOneFromResult($obj_result); 124 | } 125 | return $arr_entities; 126 | } 127 | 128 | /** 129 | * Extract a single property value from a Property object 130 | * 131 | * Defer any varying data type extractions to child classes 132 | * 133 | * @param $int_type 134 | * @param object $obj_property 135 | * @return mixed 136 | * @throws \Exception 137 | */ 138 | abstract protected function extractPropertyValue($int_type, $obj_property); 139 | 140 | /** 141 | * Auto detect & extract a value 142 | * 143 | * @param object $obj_property 144 | * @return mixed 145 | */ 146 | abstract protected function extractAutoDetectValue($obj_property); 147 | 148 | /** 149 | * Extract a datetime value 150 | * 151 | * @param $obj_property 152 | * @return mixed 153 | */ 154 | abstract protected function extractDatetimeValue($obj_property); 155 | 156 | /** 157 | * Extract a String List value 158 | * 159 | * @param $obj_property 160 | * @return mixed 161 | */ 162 | abstract protected function extractStringListValue($obj_property); 163 | 164 | /** 165 | * Extract a Geopoint value 166 | * 167 | * @param $obj_property 168 | * @return Geopoint 169 | */ 170 | abstract protected function extractGeopointValue($obj_property); 171 | 172 | /** 173 | * Map a single result out of the Raw response data array FROM Google TO a GDS Entity 174 | * 175 | * @param object $obj_result 176 | * @return Entity 177 | * @throws \Exception 178 | */ 179 | abstract public function mapOneFromResult($obj_result); 180 | 181 | } -------------------------------------------------------------------------------- /src/GDS/Schema.php: -------------------------------------------------------------------------------- 1 | 23 | * @package GDS 24 | */ 25 | class Schema 26 | { 27 | 28 | /** 29 | * Field data types 30 | */ 31 | const PROPERTY_STRING = 1; 32 | const PROPERTY_INTEGER = 2; 33 | const PROPERTY_DATETIME = 3; 34 | const PROPERTY_DOUBLE = 4; 35 | const PROPERTY_FLOAT = 4; // FLOAT === DOUBLE 36 | const PROPERTY_BLOB = 5; 37 | const PROPERTY_GEOPOINT = 6; 38 | const PROPERTY_BOOLEAN = 10; // 10 types of people... 39 | const PROPERTY_STRING_LIST = 20; 40 | const PROPERTY_INTEGER_LIST = 21; 41 | const PROPERTY_ENTITY = 30; 42 | const PROPERTY_KEY = 40; 43 | const PROPERTY_DETECT = 99; // used for auto-detection 44 | 45 | /** 46 | * Kind (like database 'Table') 47 | * 48 | * @var string|null 49 | */ 50 | private $str_kind = null; 51 | 52 | /** 53 | * Known fields 54 | * 55 | * @var array 56 | */ 57 | private $arr_defined_properties = []; 58 | 59 | /** 60 | * The class to use when instantiating new Entity objects 61 | * 62 | * @var string 63 | */ 64 | private $str_entity_class = '\\GDS\\Entity'; 65 | 66 | /** 67 | * Kind is required 68 | * 69 | * @param $str_kind 70 | */ 71 | public function __construct($str_kind) 72 | { 73 | $this->str_kind = $str_kind; 74 | } 75 | 76 | /** 77 | * Add a field to the known field array 78 | * 79 | * @param $str_name 80 | * @param $int_type 81 | * @param bool $bol_index 82 | * @return $this 83 | */ 84 | public function addProperty($str_name, $int_type = self::PROPERTY_STRING, $bol_index = TRUE) 85 | { 86 | $this->arr_defined_properties[$str_name] = [ 87 | 'type' => $int_type, 88 | 'index' => $bol_index 89 | ]; 90 | return $this; 91 | } 92 | 93 | /** 94 | * Add a string field to the schema 95 | * 96 | * @param $str_name 97 | * @param bool $bol_index 98 | * @return Schema 99 | */ 100 | public function addString($str_name, $bol_index = TRUE) 101 | { 102 | return $this->addProperty($str_name, self::PROPERTY_STRING, $bol_index); 103 | } 104 | 105 | /** 106 | * Add an integer field to the schema 107 | * 108 | * @param $str_name 109 | * @param bool $bol_index 110 | * @return Schema 111 | */ 112 | public function addInteger($str_name, $bol_index = TRUE) 113 | { 114 | return $this->addProperty($str_name, self::PROPERTY_INTEGER, $bol_index); 115 | } 116 | 117 | /** 118 | * Add a datetime field to the schema 119 | * 120 | * @param $str_name 121 | * @param bool $bol_index 122 | * @return Schema 123 | */ 124 | public function addDatetime($str_name, $bol_index = TRUE) 125 | { 126 | return $this->addProperty($str_name, self::PROPERTY_DATETIME, $bol_index); 127 | } 128 | 129 | /** 130 | * Add a float|double field to the schema 131 | * 132 | * @param $str_name 133 | * @param bool $bol_index 134 | * @return Schema 135 | */ 136 | public function addFloat($str_name, $bol_index = TRUE) 137 | { 138 | return $this->addProperty($str_name, self::PROPERTY_FLOAT, $bol_index); 139 | } 140 | 141 | /** 142 | * Add a boolean field to the schema 143 | * 144 | * @param $str_name 145 | * @param bool $bol_index 146 | * @return Schema 147 | */ 148 | public function addBoolean($str_name, $bol_index = TRUE) 149 | { 150 | return $this->addProperty($str_name, self::PROPERTY_BOOLEAN, $bol_index); 151 | } 152 | 153 | /** 154 | * Add a geopoint field to the schema 155 | * 156 | * @param $str_name 157 | * @param bool $bol_index 158 | * @return Schema 159 | */ 160 | public function addGeopoint($str_name, $bol_index = TRUE) 161 | { 162 | return $this->addProperty($str_name, self::PROPERTY_GEOPOINT, $bol_index); 163 | } 164 | 165 | /** 166 | * Add a string-list (array of strings) field to the schema 167 | * 168 | * @param $str_name 169 | * @param bool $bol_index 170 | * @return Schema 171 | */ 172 | public function addStringList($str_name, $bol_index = TRUE) 173 | { 174 | return $this->addProperty($str_name, self::PROPERTY_STRING_LIST, $bol_index); 175 | } 176 | 177 | /** 178 | * Get the Kind 179 | * 180 | * @return string 181 | */ 182 | public function getKind() 183 | { 184 | return $this->str_kind; 185 | } 186 | 187 | /** 188 | * Get the configured fields 189 | * 190 | * @return array 191 | */ 192 | public function getProperties() 193 | { 194 | return $this->arr_defined_properties; 195 | } 196 | 197 | /** 198 | * Set the class to use when instantiating new Entity objects 199 | * 200 | * Must be GDS\Entity, or a sub-class of it 201 | * 202 | * @param $str_class 203 | * @return $this 204 | * @throws \InvalidArgumentException 205 | */ 206 | public final function setEntityClass($str_class) 207 | { 208 | if(class_exists($str_class)) { 209 | if(is_a($str_class, '\\GDS\\Entity', TRUE)) { 210 | $this->str_entity_class = $str_class; 211 | } else { 212 | throw new \InvalidArgumentException('Cannot set an Entity class that does not extend "GDS\Entity": ' . $str_class); 213 | } 214 | } else { 215 | throw new \InvalidArgumentException('Cannot set missing Entity class: ' . $str_class); 216 | } 217 | return $this; 218 | } 219 | 220 | /** 221 | * Create a new instance of this GDS Entity class 222 | * 223 | * @return Entity 224 | */ 225 | public final function createEntity() 226 | { 227 | return (new $this->str_entity_class())->setSchema($this); 228 | } 229 | 230 | } -------------------------------------------------------------------------------- /tests/TimeZoneTest.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | class TimeZoneTest extends \PHPUnit\Framework\TestCase { 34 | 35 | const FORMAT_YMDHIS = 'Y-m-d H:i:s'; 36 | 37 | const DTM_KNOWN_8601 = '2004-02-12T15:19:21+00:00'; 38 | 39 | /** 40 | * Validate that creating datetime objects from 'U.u' format always results in UTC TZ 41 | */ 42 | public function testCreateDateTimeFromFormatZone() 43 | { 44 | $str_existing_tz = date_default_timezone_get(); 45 | date_default_timezone_set('America/Cayenne'); 46 | 47 | // New datetimes should be in the current timezone 48 | $obj_dtm = new DateTime(); 49 | $this->assertEquals('America/Cayenne', $obj_dtm->getTimezone()->getName()); 50 | 51 | // Using timestamp should be in UTC (or '+00:00') 52 | $obj_dtm = new DateTime('@1625652400'); 53 | $this->assertTrue(in_array($obj_dtm->getTimezone()->getName(), [Mapper::TZ_UTC, Mapper::TZ_UTC_OFFSET])); 54 | 55 | // Using string with zone 56 | $obj_dtm = new DateTime('2004-02-12T15:19:21+00:00'); 57 | $this->assertTrue(in_array($obj_dtm->getTimezone()->getName(), [Mapper::TZ_UTC, Mapper::TZ_UTC_OFFSET])); 58 | 59 | // Using string with zone 60 | $obj_dtm = new DateTime('2004-02-12T15:19:21+04:00'); 61 | $this->assertEquals('+04:00', $obj_dtm->getTimezone()->getName()); 62 | 63 | // Using 'U.u' should be in UTC (or '+00:00') 64 | $obj_dtm = DateTime::createFromFormat(Mapper::DATETIME_FORMAT_UDOTU, '1625652400.123456'); 65 | $this->assertTrue(in_array($obj_dtm->getTimezone()->getName(), [Mapper::TZ_UTC, Mapper::TZ_UTC_OFFSET])); 66 | 67 | // Using 'U.u' should be in UTC (or '+00:00') 68 | $obj_dtm = DateTime::createFromFormat(Mapper::DATETIME_FORMAT_UDOTU, '1625652400.000000'); 69 | $this->assertTrue(in_array($obj_dtm->getTimezone()->getName(), [Mapper::TZ_UTC, Mapper::TZ_UTC_OFFSET])); 70 | 71 | // Using 'U.u' should be in UTC (or '+00:00') 72 | $obj_dtm = DateTime::createFromFormat(Mapper::DATETIME_FORMAT_UDOTU, '1625652400.0'); 73 | $this->assertTrue(in_array($obj_dtm->getTimezone()->getName(), [Mapper::TZ_UTC, Mapper::TZ_UTC_OFFSET])); 74 | 75 | // Too many DP of data should fail 76 | $this->assertFalse(DateTime::createFromFormat(Mapper::DATETIME_FORMAT_UDOTU, '1625652400.999999999')); 77 | 78 | // And back to local TZ use cases 79 | $obj_dtm = new DateTime('2021-01-05 15:34:23'); 80 | $this->assertEquals('America/Cayenne', $obj_dtm->getTimezone()->getName()); 81 | $obj_dtm = new DateTime('2021-01-05'); 82 | $this->assertEquals('America/Cayenne', $obj_dtm->getTimezone()->getName()); 83 | $obj_dtm = new DateTime('01:30'); 84 | $this->assertEquals('America/Cayenne', $obj_dtm->getTimezone()->getName()); 85 | $obj_dtm = new DateTime('now'); 86 | $this->assertEquals('America/Cayenne', $obj_dtm->getTimezone()->getName()); 87 | $obj_dtm = new DateTime('yesterday'); 88 | $this->assertEquals('America/Cayenne', $obj_dtm->getTimezone()->getName()); 89 | 90 | // Reset the timezone 91 | date_default_timezone_set($str_existing_tz); 92 | } 93 | 94 | /** 95 | * Validate understanding and truncation of RFC3339 UTC "Zulu" format 96 | */ 97 | public function testMicroConversions() 98 | { 99 | // This is the example from the Google API docs, with more accuracy than PHP can handle 100 | $str_rfc_3339 = '2014-10-02T15:01:23.045123456Z'; 101 | 102 | // Some expected conversions 103 | $str_php_micros = '1412262083.045123'; 104 | $str_php_c = '2014-10-02T15:01:23+00:00'; 105 | $str_php_rfc_3339 = '2014-10-02T15:01:23.045123Z'; 106 | 107 | $obj_mapper = new RESTv1(); 108 | $obj_dtm = $obj_mapper->buildLocalisedDateTimeObjectFromUTCString($str_rfc_3339); 109 | 110 | $this->assertEquals($str_php_micros, $obj_dtm->format(Mapper::DATETIME_FORMAT_UDOTU)); 111 | $this->assertEquals($str_php_c, $obj_dtm->format('c')); 112 | $this->assertEquals($str_php_rfc_3339, $obj_dtm->format(RESTv1::DATETIME_FORMAT_ZULU)); 113 | } 114 | 115 | /** 116 | * Validate understanding and truncation of RFC3339 UTC "Zulu" format 117 | */ 118 | public function testMicroConversionsWithTimezone() 119 | { 120 | $str_existing_tz = date_default_timezone_get(); 121 | date_default_timezone_set('America/Cayenne'); 122 | 123 | // This is the example from the Google API docs, with more accuracy than PHP can handle 124 | $str_rfc_3339 = '2014-10-02T15:01:23.045123456Z'; 125 | 126 | // Some expected conversions 127 | $str_php_micros = '1412262083.045123'; 128 | $str_php_c = '2014-10-02T12:01:23-03:00'; 129 | 130 | $obj_mapper = new RESTv1(); 131 | $obj_dtm = $obj_mapper->buildLocalisedDateTimeObjectFromUTCString($str_rfc_3339); 132 | 133 | $this->assertEquals('America/Cayenne', date_default_timezone_get()); 134 | $this->assertEquals('America/Cayenne', $obj_dtm->getTimezone()->getName()); 135 | 136 | $this->assertEquals($str_php_micros, $obj_dtm->format(Mapper::DATETIME_FORMAT_UDOTU)); 137 | $this->assertEquals($str_php_c, $obj_dtm->format('c')); 138 | 139 | // Reset the timezone 140 | date_default_timezone_set($str_existing_tz); 141 | } 142 | 143 | /** 144 | * Confirm known behaviour - which is that unadjusted zulu formats are not equal 145 | * 146 | * @throws Exception 147 | */ 148 | public function testZuluFormat() 149 | { 150 | $obj_tz_utc = new DateTimeZone('UTC'); 151 | $obj_dtm1 = new DateTime('now', $obj_tz_utc); 152 | $str_utc_ts = $obj_dtm1->format(Mapper::DATETIME_FORMAT_UDOTU); 153 | 154 | $obj_tz_london = new DateTimeZone('Europe/London'); 155 | $obj_dtm1->setTimezone($obj_tz_london); 156 | $str_london_ts = $obj_dtm1->format(Mapper::DATETIME_FORMAT_UDOTU); 157 | $str_london_zulu = $obj_dtm1->format(RESTv1::DATETIME_FORMAT_ZULU); 158 | 159 | $obj_tz_nyc = new DateTimeZone('America/New_York'); 160 | $obj_dtm1->setTimezone($obj_tz_nyc); 161 | $str_nyc_ts = $obj_dtm1->format(Mapper::DATETIME_FORMAT_UDOTU); 162 | $str_nyc_zulu = $obj_dtm1->format(RESTv1::DATETIME_FORMAT_ZULU); 163 | 164 | // Timestamps always match 165 | $this->assertEquals($str_utc_ts, $str_london_ts); 166 | $this->assertEquals($str_utc_ts, $str_nyc_ts); 167 | 168 | // London and NYC never match in unadjusted zulu format 169 | $this->assertNotEquals($str_london_zulu, $str_nyc_zulu); 170 | } 171 | 172 | public function testZoneConversion() 173 | { 174 | $obj_tz_utc = new DateTimeZone('UTC'); 175 | $obj_tz_nyc = new DateTimeZone('America/New_York'); 176 | $obj_tz_xmas = new DateTimeZone('Indian/Christmas'); 177 | 178 | $obj_dtm_utc = (new DateTime(self::DTM_KNOWN_8601))->setTimezone($obj_tz_utc); 179 | $obj_dtm_nyc = (new DateTime(self::DTM_KNOWN_8601))->setTimezone($obj_tz_nyc); 180 | $obj_dtm_xmas = (new DateTime(self::DTM_KNOWN_8601))->setTimezone($obj_tz_xmas); 181 | 182 | // Timestamps match 183 | $this->assertEquals($obj_dtm_utc->format('U'), $obj_dtm_nyc->format('U')); 184 | $this->assertEquals($obj_dtm_utc->format('U'), $obj_dtm_xmas->format('U')); 185 | 186 | // Unadjusted Zulu mismatch 187 | $this->assertNotEquals($obj_dtm_utc->format(RESTv1::DATETIME_FORMAT_ZULU), $obj_dtm_nyc->format(RESTv1::DATETIME_FORMAT_ZULU)); 188 | $this->assertNotEquals($obj_dtm_utc->format(RESTv1::DATETIME_FORMAT_ZULU), $obj_dtm_xmas->format(RESTv1::DATETIME_FORMAT_ZULU)); 189 | $this->assertNotEquals($obj_dtm_nyc->format(RESTv1::DATETIME_FORMAT_ZULU), $obj_dtm_xmas->format(RESTv1::DATETIME_FORMAT_ZULU)); 190 | 191 | // Adjust value to UTC, then the outputs should match 192 | $str_zulu_utc_adjusted = $obj_dtm_utc->setTimezone($obj_tz_utc)->format(RESTv1::DATETIME_FORMAT_ZULU); 193 | $str_zulu_nyc_adjusted = $obj_dtm_nyc->setTimezone($obj_tz_utc)->format(RESTv1::DATETIME_FORMAT_ZULU); 194 | $str_zulu_xmas_adjusted = $obj_dtm_xmas->setTimezone($obj_tz_utc)->format(RESTv1::DATETIME_FORMAT_ZULU); 195 | $this->assertEquals($str_zulu_utc_adjusted, $str_zulu_nyc_adjusted); 196 | $this->assertEquals($str_zulu_utc_adjusted, $str_zulu_xmas_adjusted); 197 | 198 | // And confirm new UTC-based objects with these values match 199 | $obj_dtm_utc_from_zulu1 = DateTime::createFromFormat(RESTv1::DATETIME_FORMAT_ZULU, $str_zulu_utc_adjusted, $obj_tz_utc); 200 | $this->assertEquals( 201 | $obj_dtm_utc->format('U'), 202 | $obj_dtm_utc_from_zulu1->format('U') 203 | ); 204 | $this->assertEquals( 205 | $obj_dtm_utc->format(self::FORMAT_YMDHIS), 206 | $obj_dtm_utc_from_zulu1->format(self::FORMAT_YMDHIS) 207 | ); 208 | $this->assertEquals( 209 | $obj_dtm_utc->getTimezone()->getName(), 210 | $obj_dtm_utc_from_zulu1->getTimezone()->getName() 211 | ); 212 | } 213 | 214 | } -------------------------------------------------------------------------------- /tests/RESTv1NamespaceTest.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class RESTv1NamespaceTest extends \RESTv1Test 24 | { 25 | 26 | /** 27 | * Test upsert with namespace 28 | */ 29 | public function testUpsertWithNamespace() 30 | { 31 | $str_ns = 'SomeNamepsace'; 32 | $obj_http = $this->initTestHttpClient('https://datastore.googleapis.com/v1/projects/DatasetTest:commit', ['json' => (object)[ 33 | 'mode' => 'NON_TRANSACTIONAL', 34 | 'mutations' => [ 35 | (object)[ 36 | 'upsert' => (object)[ 37 | 'key' => (object)[ 38 | 'path' => [ 39 | (object)[ 40 | 'kind' => 'Test', 41 | 'id' => '123456789' 42 | ] 43 | ], 44 | 'partitionId' => (object)[ 45 | 'projectId' => self::TEST_PROJECT, 46 | 'namespaceId' => $str_ns 47 | ] 48 | ], 49 | 'properties' => (object)[ 50 | 'name' => (object)[ 51 | 'excludeFromIndexes' => false, 52 | 'stringValue' => 'Tom' 53 | ] 54 | ] 55 | ] 56 | ] 57 | ] 58 | ]]); 59 | $obj_gateway = $this->initTestGateway($str_ns)->setHttpClient($obj_http); 60 | 61 | $obj_store = new \GDS\Store('Test', $obj_gateway); 62 | $obj_entity = new GDS\Entity(); 63 | $obj_entity->setKeyId('123456789'); 64 | $obj_entity->name = 'Tom'; 65 | $obj_store->upsert($obj_entity); 66 | 67 | $this->validateHttpClient($obj_http); 68 | } 69 | 70 | /** 71 | * Test GQL Fetch With Namespace 72 | */ 73 | public function testGqlFetchWithNamespace() 74 | { 75 | $str_ns = 'Namespacey'; 76 | $str_id = '1263751723'; 77 | $str_id_parent = '1263751724'; 78 | $str_id_grandparent = '1263751725'; 79 | $obj_http = $this->initTestHttpClient('https://datastore.googleapis.com/v1/projects/DatasetTest:runQuery', ['json' => (object)[ 80 | 'gqlQuery' => (object)[ 81 | 'allowLiterals' => true, 82 | 'queryString' => 'SELECT * FROM Test LIMIT 1' 83 | ], 84 | 'partitionId' => (object)[ 85 | 'projectId' => self::TEST_PROJECT, 86 | 'namespaceId' => $str_ns 87 | ] 88 | 89 | ]], [ 90 | 'batch' => (object)[ 91 | 'entityResultType' => 'FULL', 92 | 'entityResults' => [ 93 | // Entity with key and properties 94 | (object)[ 95 | 'entity' => (object)[ 96 | 'key' => (object)[ 97 | 'path' => [ 98 | (object)[ 99 | 'kind' => 'GrandParent', 100 | 'id' => $str_id_grandparent 101 | ], 102 | (object)[ 103 | 'kind' => 'Parent', 104 | 'id' => $str_id_parent 105 | ], 106 | (object)[ 107 | 'kind' => 'Test', 108 | 'id' => $str_id 109 | ] 110 | ] 111 | ], 112 | 'properties' => (object)[ 113 | 'name' => (object)[ 114 | 'excludeFromIndexes' => false, 115 | 'stringValue' => 'Tom' 116 | ], 117 | 'age' => (object)[ 118 | 'excludeFromIndexes' => false, 119 | 'integerValue' => 37 120 | ] 121 | 122 | 123 | ] 124 | ], 125 | 'version' => '123', 126 | 'cursor' => 'gfuh37f86gyu23' 127 | ] 128 | ] 129 | ] 130 | ]); 131 | $obj_gateway = $this->initTestGateway($str_ns)->setHttpClient($obj_http); 132 | 133 | $obj_store = new \GDS\Store('Test', $obj_gateway); 134 | $obj_entity = $obj_store->fetchOne("SELECT * FROM Test"); 135 | 136 | $this->assertInstanceOf('\\GDS\\Entity', $obj_entity); 137 | $this->assertEquals($str_id, $obj_entity->getKeyId()); 138 | $this->assertEquals('Tom', $obj_entity->name); 139 | $this->assertEquals(37, $obj_entity->age); 140 | 141 | // Do we have ancestry? 142 | $this->assertTrue(is_array($obj_entity->getAncestry())); 143 | $this->assertEquals(2, count($obj_entity->getAncestry())); 144 | 145 | // Extract the ancestry 146 | $arr_ancestry = $obj_entity->getAncestry(); 147 | $arr_grandparent = $arr_ancestry[0]; 148 | $arr_parent = $arr_ancestry[1]; 149 | 150 | // Grandparent tests 151 | $this->assertArrayHasKey('kind', $arr_grandparent); 152 | $this->assertEquals('GrandParent', $arr_grandparent['kind']); 153 | $this->assertArrayHasKey('id', $arr_grandparent); 154 | $this->assertEquals($str_id_grandparent, $arr_grandparent['id']); 155 | 156 | // Parent test 157 | $this->assertArrayHasKey('kind', $arr_parent); 158 | $this->assertEquals('Parent', $arr_parent['kind']); 159 | $this->assertArrayHasKey('id', $arr_parent); 160 | $this->assertEquals($str_id_parent, $arr_parent['id']); 161 | 162 | $this->validateHttpClient($obj_http); 163 | } 164 | 165 | /** 166 | * Test fetch by key with namespace 167 | */ 168 | public function testFetchByKeyWithNamespace() 169 | { 170 | $str_ns = 'SpaceTheFinalFrontier'; 171 | $str_id = '1263751723'; 172 | $obj_http = $this->initTestHttpClient('https://datastore.googleapis.com/v1/projects/DatasetTest:lookup', ['json' => (object)[ 173 | 'keys' => [ 174 | (object)[ 175 | 'path' => [ 176 | (object)[ 177 | 'kind' => 'Test', 178 | 'id' => $str_id 179 | ] 180 | ], 181 | 'partitionId' => (object)[ 182 | 'projectId' => self::TEST_PROJECT, 183 | 'namespaceId' => $str_ns 184 | ] 185 | ] 186 | ] 187 | ]], [ 188 | 'found' => [ 189 | (object)[ 190 | 'entity' => (object)[ 191 | 'key' => (object)[ 192 | 'path' => [ 193 | (object)[ 194 | 'kind' => 'Test', 195 | 'id' => $str_id 196 | ] 197 | ] 198 | ], 199 | 'properties' => (object)[ 200 | 'name' => (object)[ 201 | 'excludeFromIndexes' => false, 202 | 'stringValue' => 'Tom' 203 | ] 204 | ] 205 | ], 206 | 'version' => '123', 207 | 'cursor' => 'gfuh37f86gyu23' 208 | ] 209 | ] 210 | ]); 211 | $obj_gateway = $this->initTestGateway($str_ns)->setHttpClient($obj_http); 212 | 213 | $obj_store = new \GDS\Store('Test', $obj_gateway); 214 | $obj_entity = $obj_store->fetchById($str_id); 215 | 216 | $this->assertInstanceOf('\\GDS\\Entity', $obj_entity); 217 | $this->assertEquals($str_id, $obj_entity->getKeyId()); 218 | $this->assertEquals('Tom', $obj_entity->name); 219 | 220 | 221 | $this->validateHttpClient($obj_http); 222 | } 223 | 224 | /** 225 | * Test delete with namespace 226 | */ 227 | public function testDeleteWithNamespace() 228 | { 229 | $str_ns = 'TestNameSpace'; 230 | $obj_http = $this->initTestHttpClient('https://datastore.googleapis.com/v1/projects/DatasetTest:commit', ['json' => (object)[ 231 | 'mode' => 'NON_TRANSACTIONAL', 232 | 'mutations' => [ 233 | (object)[ 234 | 'delete' => (object)[ 235 | 'path' => [ 236 | (object)[ 237 | 'kind' => 'Test', 238 | 'id' => '123456789' 239 | ] 240 | ], 241 | 'partitionId' => (object)[ 242 | 'projectId' => self::TEST_PROJECT, 243 | 'namespaceId' => $str_ns 244 | ] 245 | ] 246 | ] 247 | ] 248 | ]]); 249 | $obj_gateway = $this->initTestGateway($str_ns)->setHttpClient($obj_http); 250 | 251 | $obj_store = new \GDS\Store('Test', $obj_gateway); 252 | $obj_entity = (new GDS\Entity())->setKeyId('123456789'); 253 | $obj_store->delete([$obj_entity]); 254 | 255 | $this->validateHttpClient($obj_http); 256 | } 257 | 258 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /src/GDS/Store.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | class Store 27 | { 28 | 29 | /** 30 | * The GDS Gateway we're going to use 31 | * 32 | * @var Gateway 33 | */ 34 | private $obj_gateway = null; 35 | 36 | /** 37 | * The GDS Schema defining the Entity we're operating with 38 | * 39 | * @var Schema 40 | */ 41 | private $obj_schema = null; 42 | 43 | /** 44 | * The last GQL query 45 | * 46 | * @var string|null 47 | */ 48 | private $str_last_query = null; 49 | 50 | /** 51 | * Named parameters for the last query 52 | * 53 | * @var array|null 54 | */ 55 | private $arr_last_params = null; 56 | 57 | /** 58 | * The last result cursor 59 | * 60 | * @var string|null 61 | */ 62 | private $str_last_cursor = null; 63 | 64 | /** 65 | * Transaction ID 66 | * 67 | * @var null|string 68 | */ 69 | private $str_transaction_id = null; 70 | 71 | /** 72 | * Gateway and Schema/Kind can be supplied on construction 73 | * 74 | * @param Schema|string|null $kind_schema 75 | * @param Gateway $obj_gateway 76 | * @throws \Exception 77 | */ 78 | public function __construct($kind_schema = null, Gateway $obj_gateway = null) 79 | { 80 | $this->obj_schema = $this->determineSchema($kind_schema); 81 | if (null === $obj_gateway) { 82 | $obj_gateway = new \GDS\Gateway\RESTv1(Gateway::determineProjectId()); 83 | } 84 | $this->obj_gateway = $obj_gateway; 85 | $this->str_last_query = 'SELECT * FROM `' . $this->obj_schema->getKind() . '` ORDER BY __key__ ASC'; 86 | } 87 | 88 | /** 89 | * Set up the Schema for the current data model, based on the provided Kind/Schema/buildSchema 90 | * 91 | * @param Schema|string|null $mix_schema 92 | * @return Schema 93 | * @throws \Exception 94 | */ 95 | private function determineSchema($mix_schema): Schema 96 | { 97 | if(null === $mix_schema) { 98 | $mix_schema = $this->buildSchema(); 99 | } 100 | if ($mix_schema instanceof Schema) { 101 | return $mix_schema; 102 | } 103 | if (is_string($mix_schema)) { 104 | return new Schema($mix_schema); 105 | } 106 | throw new \Exception('You must provide a Schema or Kind. Alternatively, you can extend GDS\Store and implement the buildSchema() method.'); 107 | } 108 | 109 | /** 110 | * Write one or more new/changed Entity objects to the Datastore 111 | * 112 | * @todo Consider returning the input 113 | * 114 | * @param Entity|Entity[] 115 | */ 116 | public function upsert($entities) 117 | { 118 | if($entities instanceof Entity) { 119 | $entities = [$entities]; 120 | } 121 | $this->obj_gateway 122 | ->withSchema($this->obj_schema) 123 | ->withTransaction($this->consumeTransaction()) 124 | ->putMulti($entities); 125 | } 126 | 127 | /** 128 | * Delete one or more Model objects from the Datastore 129 | * 130 | * @param mixed 131 | * @return bool 132 | */ 133 | public function delete($entities) 134 | { 135 | if($entities instanceof Entity) { 136 | $entities = [$entities]; 137 | } 138 | return $this->obj_gateway 139 | ->withSchema($this->obj_schema) 140 | ->withTransaction($this->consumeTransaction()) 141 | ->deleteMulti($entities); 142 | } 143 | 144 | /** 145 | * Fetch a single Entity from the Datastore, by it's Key ID 146 | * 147 | * Only works for root Entities (i.e. those without parent Entities) 148 | * 149 | * @param $str_id 150 | * @return Entity|null 151 | */ 152 | public function fetchById($str_id) 153 | { 154 | return $this->obj_gateway 155 | ->withSchema($this->obj_schema) 156 | ->withTransaction($this->str_transaction_id) 157 | ->fetchById($str_id); 158 | } 159 | 160 | /** 161 | * Fetch multiple entities by Key ID 162 | * 163 | * @param $arr_ids 164 | * @return Entity[] 165 | */ 166 | public function fetchByIds(array $arr_ids) 167 | { 168 | return $this->obj_gateway 169 | ->withSchema($this->obj_schema) 170 | ->withTransaction($this->str_transaction_id) 171 | ->fetchByIds($arr_ids); 172 | } 173 | 174 | /** 175 | * Fetch a single Entity from the Datastore, by it's Key Name 176 | * 177 | * Only works for root Entities (i.e. those without parent Entities) 178 | * 179 | * @param $str_name 180 | * @return Entity|null 181 | */ 182 | public function fetchByName($str_name) 183 | { 184 | return $this->obj_gateway 185 | ->withSchema($this->obj_schema) 186 | ->withTransaction($this->str_transaction_id) 187 | ->fetchByName($str_name); 188 | } 189 | 190 | /** 191 | * Fetch one or more Entities from the Datastore, by their Key Name 192 | * 193 | * Only works for root Entities (i.e. those without parent Entities) 194 | * 195 | * @param $arr_names 196 | * @return Entity[]|null 197 | */ 198 | public function fetchByNames(array $arr_names) 199 | { 200 | return $this->obj_gateway 201 | ->withSchema($this->obj_schema) 202 | ->withTransaction($this->str_transaction_id) 203 | ->fetchByNames($arr_names); 204 | } 205 | 206 | /** 207 | * Fetch Entities based on a GQL query 208 | * 209 | * Supported parameter types: String, Integer, DateTime, GDS\Entity 210 | * 211 | * @param $str_query 212 | * @param array|null $arr_params 213 | * @return $this 214 | */ 215 | public function query($str_query, $arr_params = null) 216 | { 217 | $this->str_last_query = $str_query; 218 | $this->arr_last_params = $arr_params; 219 | $this->str_last_cursor = null; 220 | return $this; 221 | } 222 | 223 | /** 224 | * Fetch ONE Entity based on a GQL query 225 | * 226 | * @param $str_query 227 | * @param array|null $arr_params 228 | * @return Entity 229 | */ 230 | public function fetchOne($str_query = null, $arr_params = null) 231 | { 232 | if(null !== $str_query) { 233 | $this->query($str_query, $arr_params); 234 | } 235 | $arr_results = $this->obj_gateway 236 | ->withSchema($this->obj_schema) 237 | ->withTransaction($this->str_transaction_id) 238 | ->gql($this->str_last_query . ' LIMIT 1', $this->arr_last_params); 239 | return count($arr_results) > 0 ? $arr_results[0] : null; 240 | } 241 | 242 | /** 243 | * Fetch Entities (optionally based on a GQL query) 244 | * 245 | * @param $str_query 246 | * @param array|null $arr_params 247 | * @return Entity[] 248 | */ 249 | public function fetchAll($str_query = null, $arr_params = null) 250 | { 251 | if(null !== $str_query) { 252 | $this->query($str_query, $arr_params); 253 | } 254 | $arr_results = $this->obj_gateway 255 | ->withSchema($this->obj_schema) 256 | ->withTransaction($this->str_transaction_id) 257 | ->gql($this->str_last_query, $this->arr_last_params); 258 | return $arr_results; 259 | } 260 | 261 | /** 262 | * Fetch (a page of) Entities (optionally based on a GQL query) 263 | * 264 | * @param $int_page_size 265 | * @param null $mix_offset 266 | * @return Entity[] 267 | */ 268 | public function fetchPage($int_page_size, $mix_offset = null) 269 | { 270 | $str_offset = ''; 271 | $arr_params = (array)$this->arr_last_params; 272 | $arr_params['intPageSize'] = $int_page_size; 273 | if(null !== $mix_offset) { 274 | if(is_int($mix_offset)) { 275 | $str_offset = 'OFFSET @intOffset'; 276 | $arr_params['intOffset'] = $mix_offset; 277 | } else { 278 | $str_offset = 'OFFSET @startCursor'; 279 | $arr_params['startCursor'] = $mix_offset; 280 | } 281 | } else if (!is_null($this->str_last_cursor) && strlen($this->str_last_cursor) > 1) { 282 | $str_offset = 'OFFSET @startCursor'; 283 | $arr_params['startCursor'] = $this->str_last_cursor; 284 | } 285 | $arr_results = $this->obj_gateway 286 | ->withSchema($this->obj_schema) 287 | ->withTransaction($this->str_transaction_id) 288 | ->gql($this->str_last_query . " LIMIT @intPageSize {$str_offset}", $arr_params); 289 | $this->str_last_cursor = $this->obj_gateway->getEndCursor(); 290 | return $arr_results; 291 | } 292 | 293 | /** 294 | * Fetch all of the entities in a particular group 295 | * 296 | * @param Entity $obj_entity 297 | * @return Entity[] 298 | */ 299 | public function fetchEntityGroup(Entity $obj_entity) 300 | { 301 | $arr_results = $this->obj_gateway 302 | ->withSchema($this->obj_schema) 303 | ->withTransaction($this->str_transaction_id) 304 | ->gql("SELECT * FROM `" . $this->obj_schema->getKind() . "` WHERE __key__ HAS ANCESTOR @ancestorKey", [ 305 | 'ancestorKey' => $obj_entity 306 | ]); 307 | $this->str_last_cursor = $this->obj_gateway->getEndCursor(); 308 | return $arr_results; 309 | } 310 | 311 | /** 312 | * Get the last result cursor 313 | * 314 | * @return null|string 315 | */ 316 | public function getCursor() 317 | { 318 | return $this->str_last_cursor; 319 | } 320 | 321 | /** 322 | * Set the query cursor 323 | * 324 | * Usually before continuing through a paged result set 325 | * 326 | * @param $str_cursor 327 | * @return $this 328 | */ 329 | public function setCursor($str_cursor) 330 | { 331 | $this->str_last_cursor = $str_cursor; 332 | return $this; 333 | } 334 | 335 | /** 336 | * Create a new instance of this GDS Entity class 337 | * 338 | * @param array|null $arr_data 339 | * @return Entity 340 | */ 341 | public final function createEntity($arr_data = null) 342 | { 343 | $obj_entity = $this->obj_schema->createEntity(); 344 | if(null !== $arr_data) { 345 | foreach ($arr_data as $str_property => $mix_value) { 346 | $obj_entity->__set($str_property, $mix_value); 347 | } 348 | } 349 | return $obj_entity; 350 | } 351 | 352 | /** 353 | * Set the class to use when instantiating new Entity objects 354 | * 355 | * Must be GDS\Entity, or a sub-class of it 356 | * 357 | * This method is here to maintain backwards compatibility. The Schema is responsible in 2.0+ 358 | * 359 | * @param $str_class 360 | * @return $this 361 | * @throws \Exception 362 | */ 363 | public function setEntityClass($str_class) 364 | { 365 | $this->obj_schema->setEntityClass($str_class); 366 | return $this; 367 | } 368 | 369 | /** 370 | * Begin a transaction 371 | * 372 | * @param bool $bol_cross_group 373 | * @return $this 374 | */ 375 | public function beginTransaction($bol_cross_group = FALSE) 376 | { 377 | $this->str_transaction_id = $this->obj_gateway->beginTransaction($bol_cross_group); 378 | return $this; 379 | } 380 | 381 | /** 382 | * Clear and return the current transaction ID 383 | * 384 | * @return string|null 385 | */ 386 | private function consumeTransaction() 387 | { 388 | $str_transaction_id = $this->str_transaction_id; 389 | $this->str_transaction_id = null; 390 | return $str_transaction_id; 391 | } 392 | 393 | /** 394 | * Optionally build and return a Schema object describing the data model 395 | * 396 | * This method is intended to be overridden in any extended Store classes 397 | * 398 | * @return Schema|null 399 | */ 400 | protected function buildSchema() 401 | { 402 | return null; 403 | } 404 | 405 | } 406 | -------------------------------------------------------------------------------- /src/GDS/Gateway.php: -------------------------------------------------------------------------------- 1 | 28 | * @package GDS 29 | */ 30 | abstract class Gateway 31 | { 32 | // 8 = about 5 seconds total, with last gap ~2.5 seconds 33 | const RETRY_MAX_ATTEMPTS = 8; 34 | 35 | /** 36 | * The dataset ID 37 | * 38 | * @var string|null 39 | */ 40 | protected $str_dataset_id = null; 41 | 42 | /** 43 | * Optional namespace (for multi-tenant applications) 44 | * 45 | * @var string|null 46 | */ 47 | protected $str_namespace = null; 48 | 49 | /** 50 | * The last response - usually a Commit or Query response 51 | * 52 | * @var object|null 53 | */ 54 | protected $obj_last_response = null; 55 | 56 | /** 57 | * The transaction ID to use on the next commit 58 | * 59 | * @var null|string 60 | */ 61 | protected $str_next_transaction = null; 62 | 63 | /** 64 | * The current Schema 65 | * 66 | * @var Schema|null 67 | */ 68 | protected $obj_schema = null; 69 | 70 | /** 71 | * An array of Mappers, keyed on Entity Kind 72 | * 73 | * @var \GDS\Mapper[] 74 | */ 75 | protected $arr_kind_mappers = []; 76 | 77 | protected static $bol_retry = false; 78 | 79 | /** 80 | * Configure gateway retries (for 503, 500 responses) 81 | * 82 | * @param bool $bol_retry 83 | * @return void 84 | */ 85 | public static function exponentialBackoff(bool $bol_retry = true) 86 | { 87 | self::$bol_retry = $bol_retry; 88 | } 89 | 90 | /** 91 | * Set the Schema to be used next (once?) 92 | * 93 | * @param Schema $obj_schema 94 | * @return $this 95 | */ 96 | public function withSchema(Schema $obj_schema) 97 | { 98 | $this->obj_schema = $obj_schema; 99 | return $this; 100 | } 101 | 102 | /** 103 | * Set the transaction ID to be used next (once) 104 | * 105 | * @param $str_transaction_id 106 | * @return $this 107 | */ 108 | public function withTransaction($str_transaction_id) 109 | { 110 | $this->str_next_transaction = $str_transaction_id; 111 | return $this; 112 | } 113 | 114 | /** 115 | * Fetch one entity by Key ID 116 | * 117 | * @param $int_key_id 118 | * @return mixed 119 | */ 120 | public function fetchById($int_key_id) 121 | { 122 | $arr_results = $this->fetchByIds([$int_key_id]); 123 | if(count($arr_results) > 0) { 124 | return $arr_results[0]; 125 | } 126 | return null; 127 | } 128 | 129 | /** 130 | * Fetch entity data by Key Name 131 | * 132 | * @param $str_key_name 133 | * @return mixed 134 | */ 135 | public function fetchByName($str_key_name) 136 | { 137 | $arr_results = $this->fetchByNames([$str_key_name]); 138 | if(count($arr_results) > 0) { 139 | return $arr_results[0]; 140 | } 141 | return null; 142 | } 143 | 144 | /** 145 | * Delete an Entity 146 | * 147 | * @param Entity $obj_key 148 | * @return bool 149 | */ 150 | public function delete(Entity $obj_key) 151 | { 152 | return $this->deleteMulti([$obj_key]); 153 | } 154 | 155 | /** 156 | * Put a single Entity into the Datastore 157 | * 158 | * @param Entity $obj_entity 159 | */ 160 | public function put(Entity $obj_entity) 161 | { 162 | $this->putMulti([$obj_entity]); 163 | } 164 | 165 | /** 166 | * Put an array of Entities into the Datastore 167 | * 168 | * Consumes Schema 169 | * 170 | * @param \GDS\Entity[] $arr_entities 171 | * @throws \Exception 172 | */ 173 | public function putMulti(array $arr_entities) 174 | { 175 | // Ensure all the supplied are Entities and have a Kind & Schema 176 | $this->ensureSchema($arr_entities); 177 | 178 | // Record the Auto-generated Key IDs against the GDS Entities. 179 | $this->mapAutoIDs($this->upsert($arr_entities)); 180 | 181 | // Consume schema, clear kind mapper-map(!) 182 | $this->obj_schema = null; 183 | $this->arr_kind_mappers = []; 184 | } 185 | 186 | /** 187 | * Fetch one or more entities by KeyID 188 | * 189 | * Consumes Schema (deferred) 190 | * 191 | * @param array $arr_key_ids 192 | * @return array 193 | */ 194 | public function fetchByIds(array $arr_key_ids) 195 | { 196 | return $this->fetchByKeyPart($arr_key_ids, 'setId'); 197 | } 198 | 199 | /** 200 | * Fetch one or more entities by KeyName 201 | * 202 | * Consume Schema (deferred) 203 | * 204 | * @param array $arr_key_names 205 | * @return array 206 | */ 207 | public function fetchByNames(array $arr_key_names) 208 | { 209 | return $this->fetchByKeyPart($arr_key_names, 'setName'); 210 | } 211 | 212 | /** 213 | * Attempt to extract the current Google project ID 214 | * 215 | * @return string 216 | */ 217 | public static function determineProjectId(): string 218 | { 219 | static $str_project_id = null; 220 | if (null !== $str_project_id) { 221 | return $str_project_id; 222 | } 223 | $arr_env = getenv(); 224 | $str_project_id = $arr_env['GOOGLE_CLOUD_PROJECT'] ?? null; 225 | if (null === $str_project_id) { 226 | $str_project_id = $arr_env['DATASTORE_PROJECT_ID'] ?? null; 227 | } 228 | if (null === $str_project_id) { 229 | $str_project_id = $arr_env['DATASTORE_DATASET'] ?? null; 230 | } 231 | if (null === $str_project_id) { 232 | $str_project_id = $_SERVER['GOOGLE_CLOUD_PROJECT'] ?? null; 233 | } 234 | if (null === $str_project_id) { 235 | try { 236 | if (class_exists('\Google\Auth\Credentials\GCECredentials')) { 237 | $str_project_id = (new \Google\Auth\Credentials\GCECredentials())->getProjectId(); 238 | } 239 | } catch (\Throwable $obj_thrown) { 240 | // Silent 241 | } 242 | } 243 | if (null === $str_project_id) { 244 | throw new \RuntimeException('Could not determine project ID, please configure your Gateway'); 245 | } 246 | return $str_project_id; 247 | } 248 | 249 | /** 250 | * Default Kind & Schema support for "new" Entities 251 | * 252 | * @param \GDS\Entity[] $arr_entities 253 | */ 254 | protected function ensureSchema(array $arr_entities) 255 | { 256 | foreach($arr_entities as $obj_gds_entity) { 257 | if($obj_gds_entity instanceof Entity) { 258 | if (null === $obj_gds_entity->getKind()) { 259 | $obj_gds_entity->setSchema($this->obj_schema); 260 | } 261 | } else { 262 | throw new \InvalidArgumentException('You gave me something other than GDS\Entity objects.. not gonna fly!'); 263 | } 264 | } 265 | } 266 | 267 | /** 268 | * Determine Mapper (early stage [draft] support for cross-entity upserts) 269 | * 270 | * @param Entity $obj_gds_entity 271 | * @return Mapper 272 | */ 273 | protected function determineMapper(Entity $obj_gds_entity) 274 | { 275 | $str_this_kind = $obj_gds_entity->getKind(); 276 | if(!isset($this->arr_kind_mappers[$str_this_kind])) { 277 | $this->arr_kind_mappers[$str_this_kind] = $this->createMapper(); 278 | if($this->obj_schema->getKind() != $str_this_kind) { 279 | $this->arr_kind_mappers[$str_this_kind]->setSchema($obj_gds_entity->getSchema()); 280 | } 281 | } 282 | return $this->arr_kind_mappers[$str_this_kind]; 283 | } 284 | 285 | /** 286 | * Record the Auto-generated Key IDs against the GDS Entities. 287 | * 288 | * @param \GDS\Entity[] $arr_auto_id_requested 289 | * @throws \Exception 290 | */ 291 | protected function mapAutoIDs(array $arr_auto_id_requested) 292 | { 293 | if (!empty($arr_auto_id_requested)) { 294 | $arr_auto_ids = $this->extractAutoIDs(); 295 | if(count($arr_auto_id_requested) === count($arr_auto_ids)) { 296 | foreach ($arr_auto_id_requested as $int_idx => $obj_gds_entity) { 297 | $obj_gds_entity->setKeyId($arr_auto_ids[$int_idx]); 298 | } 299 | } else { 300 | throw new \Exception("Mismatch count of requested & returned Auto IDs"); 301 | } 302 | } 303 | } 304 | 305 | /** 306 | * Part of our "add parameters to query" sequence. 307 | * 308 | * Shared between multiple Gateway implementations. 309 | * 310 | * @param $obj_val 311 | * @param $mix_value 312 | * @return $obj_val 313 | */ 314 | protected function configureValueParamForQuery($obj_val, $mix_value) 315 | { 316 | $str_type = gettype($mix_value); 317 | switch($str_type) { 318 | case 'boolean': 319 | $obj_val->setBooleanValue($mix_value); 320 | break; 321 | 322 | case 'integer': 323 | $obj_val->setIntegerValue($mix_value); 324 | break; 325 | 326 | case 'double': 327 | $obj_val->setDoubleValue($mix_value); 328 | break; 329 | 330 | case 'string': 331 | $obj_val->setStringValue($mix_value); 332 | break; 333 | 334 | case 'array': 335 | throw new \InvalidArgumentException('Unexpected array parameter'); 336 | 337 | case 'object': 338 | $this->configureObjectValueParamForQuery($obj_val, $mix_value); 339 | break; 340 | 341 | case 'NULL': 342 | $obj_val->setStringValue(null); 343 | break; 344 | 345 | case 'resource': 346 | case 'unknown type': 347 | default: 348 | throw new \InvalidArgumentException('Unsupported parameter type: ' . $str_type); 349 | } 350 | return $obj_val; 351 | } 352 | 353 | /** 354 | * Delay execution, based on the attempt number 355 | * 356 | * @param int $int_attempt 357 | * @return void 358 | */ 359 | protected function backoff(int $int_attempt) 360 | { 361 | $int_backoff = (int) pow(2, $int_attempt); 362 | $int_jitter = rand(0, 10) * 1000; 363 | $int_delay = ($int_backoff * 10000) + $int_jitter; 364 | usleep($int_delay); 365 | } 366 | 367 | /** 368 | * Execute the callback with exponential backoff 369 | * 370 | * @param callable $fnc_main 371 | * @param string|null $str_exception 372 | * @param callable|null $fnc_resolve_exception 373 | * @return mixed 374 | * @throws \Throwable 375 | */ 376 | protected function executeWithExponentialBackoff( 377 | callable $fnc_main, 378 | string $str_exception = null, 379 | callable $fnc_resolve_exception = null 380 | ) { 381 | $int_attempt = 0; 382 | $bol_retry_once = false; 383 | do { 384 | try { 385 | $int_attempt++; 386 | if ($int_attempt > 1) { 387 | $this->backoff($int_attempt); 388 | } 389 | return $fnc_main(); 390 | } catch (\Throwable $obj_thrown) { 391 | // Rethrow if we're not interested in this Exception type 392 | if (null !== $str_exception && !$obj_thrown instanceof $str_exception) { 393 | throw $obj_thrown; 394 | } 395 | // Rethrow if retry is disabled, non-retryable errors, or if we have hit a retry limit 396 | if (false === self::$bol_retry || 397 | true === $bol_retry_once || 398 | !in_array((int) $obj_thrown->getCode(), static::RETRY_ERROR_CODES) 399 | ) { 400 | throw null === $fnc_resolve_exception ? $obj_thrown : $fnc_resolve_exception($obj_thrown); 401 | } 402 | // Just one retry for some errors 403 | if (in_array((int) $obj_thrown->getCode(), static::RETRY_ONCE_CODES)) { 404 | $bol_retry_once = true; 405 | } 406 | } 407 | } while ($int_attempt < self::RETRY_MAX_ATTEMPTS); 408 | 409 | // We could not make this work after max retries 410 | throw null === $fnc_resolve_exception ? $obj_thrown : $fnc_resolve_exception($obj_thrown); 411 | } 412 | 413 | /** 414 | * Configure a Value parameter, based on the supplied object-type value 415 | * 416 | * @param object $obj_val 417 | * @param object $mix_value 418 | */ 419 | abstract protected function configureObjectValueParamForQuery($obj_val, $mix_value); 420 | 421 | /** 422 | * Put an array of Entities into the Datastore. Return any that need AutoIDs 423 | * 424 | * @param \GDS\Entity[] $arr_entities 425 | * @return \GDS\Entity[] 426 | */ 427 | abstract protected function upsert(array $arr_entities); 428 | 429 | /** 430 | * Extract Auto Insert IDs from the last response 431 | * 432 | * @return array 433 | */ 434 | abstract protected function extractAutoIDs(); 435 | 436 | /** 437 | * Fetch 1-many Entities, using the Key parts provided 438 | * 439 | * Consumes Schema 440 | * 441 | * @param array $arr_key_parts 442 | * @param $str_setter 443 | * @return mixed 444 | */ 445 | abstract protected function fetchByKeyPart(array $arr_key_parts, $str_setter); 446 | 447 | /** 448 | * Delete 1-many entities 449 | * 450 | * @param array $arr_entities 451 | * @return mixed 452 | */ 453 | abstract public function deleteMulti(array $arr_entities); 454 | 455 | /** 456 | * Fetch some Entities, based on the supplied GQL and, optionally, parameters 457 | * 458 | * @param string $str_gql 459 | * @param null|array $arr_params 460 | * @return mixed 461 | */ 462 | abstract public function gql($str_gql, $arr_params = null); 463 | 464 | /** 465 | * Get the end cursor from the last response 466 | * 467 | * @return mixed 468 | */ 469 | abstract public function getEndCursor(); 470 | 471 | /** 472 | * Create a mapper that's right for this Gateway 473 | * 474 | * @return Mapper 475 | */ 476 | abstract protected function createMapper(); 477 | 478 | /** 479 | * Start a transaction 480 | * 481 | * @param bool $bol_cross_group 482 | * @return mixed 483 | */ 484 | abstract public function beginTransaction($bol_cross_group = FALSE); 485 | 486 | } -------------------------------------------------------------------------------- /src/GDS/Mapper/GRPCv1.php: -------------------------------------------------------------------------------- 1 | 37 | * 38 | * @author Samuel Melrose 39 | * @author Tom Walder 40 | */ 41 | class GRPCv1 extends \GDS\Mapper 42 | { 43 | 44 | /** 45 | * Project & Namespace Info for Keys 46 | * 47 | * @todo review use of this is the Mapper - move to Gateway? 48 | */ 49 | private $obj_partition_id; 50 | 51 | /** 52 | * Set the partition (project & namespace) object internally for key generation. 53 | * 54 | * @param PartitionId $obj_partition_id 55 | * @return GRPCv1 56 | */ 57 | public function setPartitionId($obj_partition_id) 58 | { 59 | $this->obj_partition_id = $obj_partition_id; 60 | return $this; 61 | } 62 | 63 | /** 64 | * Map from GDS to gRPC object. 65 | * 66 | * @param Entity $obj_gds_entity 67 | * @param GRPC_Entity $obj_entity 68 | * @throws \Exception 69 | */ 70 | public function mapToGoogle(Entity $obj_gds_entity, GRPC_Entity $obj_entity) 71 | { 72 | // Key 73 | $obj_entity->setKey($this->createGoogleKey($obj_gds_entity)); 74 | 75 | // Properties 76 | $arr_props = []; 77 | $arr_field_defs = $this->obj_schema->getProperties(); 78 | foreach($obj_gds_entity->getData() as $str_field_name => $mix_value) { 79 | if(isset($arr_field_defs[$str_field_name])) { 80 | $arr_props[$str_field_name] = $this->configureGooglePropertyValue($arr_field_defs[$str_field_name], $mix_value); 81 | } else { 82 | $arr_dynamic_data = $this->determineDynamicType($mix_value); 83 | $arr_props[$str_field_name] = $this->configureGooglePropertyValue(['type' => $arr_dynamic_data['type'], 'index' => TRUE], $arr_dynamic_data['value']); 84 | } 85 | } 86 | 87 | $obj_entity->setProperties($arr_props); 88 | } 89 | 90 | /** 91 | * Map a single result out of the Raw response data into a supplied Entity object 92 | * 93 | * @todo Validate dynamic schema mapping in multi-kind responses like fetchEntityGroup() 94 | * 95 | * @param GRPC_EntityResult $obj_result 96 | * @return Entity 97 | * @throws \Exception 98 | */ 99 | public function mapOneFromResult($obj_result) 100 | { 101 | // Key & Ancestry 102 | list($obj_gds_entity, $bol_schema_match) = $this->createEntityWithKey($obj_result); 103 | 104 | // Properties 105 | $arr_property_definitions = $this->obj_schema->getProperties(); 106 | foreach($obj_result->getEntity()->getProperties() as $str_field => $obj_property) { 107 | /** @var Value $obj_property */ 108 | if ($bol_schema_match && isset($arr_property_definitions[$str_field])) { 109 | $int_type = $arr_property_definitions[$str_field]['type']; 110 | } else { 111 | $int_type = Schema::PROPERTY_DETECT; 112 | } 113 | $obj_gds_entity->__set($str_field, $this->extractPropertyValue($int_type, $obj_property)); 114 | } 115 | return $obj_gds_entity; 116 | } 117 | 118 | /** 119 | * Convert a RepeatedField to a standard array, 120 | * as it isn't compatible with the usual array functions. 121 | * 122 | * @param RepeatedField $rep 123 | * @return array 124 | */ 125 | public function convertRepeatedField(RepeatedField $rep) 126 | { 127 | $arr = []; 128 | foreach ($rep as $v) { 129 | $arr[] = $v; 130 | } 131 | return $arr; 132 | } 133 | 134 | /** 135 | * Create & populate a GDS\Entity with key data 136 | * 137 | * @todo Validate dynamic mapping 138 | * 139 | * @param GRPC_EntityResult $obj_result 140 | * @return array 141 | */ 142 | private function createEntityWithKey(GRPC_EntityResult $obj_result) 143 | { 144 | // Get the full key path 145 | $arr_key_path = $this->convertRepeatedField($obj_result->getEntity()->getKey()->getPath()); 146 | 147 | // Key for 'self' (the last part of the KEY PATH) 148 | /* @var $obj_path_end \google\appengine\datastore\v4\Key\PathElement */ 149 | $obj_path_end = array_pop($arr_key_path); 150 | if($obj_path_end->getKind() == $this->obj_schema->getKind()) { 151 | $bol_schema_match = TRUE; 152 | $obj_gds_entity = $this->obj_schema->createEntity(); 153 | } else { 154 | $bol_schema_match = FALSE; 155 | $obj_gds_entity = (new \GDS\Entity())->setKind($obj_path_end->getKind()); 156 | } 157 | 158 | // Set ID or Name (will always have one or the other) 159 | if($obj_path_end->getIdType() == 'id') { 160 | $obj_gds_entity->setKeyId($obj_path_end->getId()); 161 | } else { 162 | $obj_gds_entity->setKeyName($obj_path_end->getName()); 163 | } 164 | 165 | // Ancestors? 166 | $int_ancestor_elements = count($arr_key_path); 167 | if($int_ancestor_elements > 0) { 168 | $arr_anc_path = []; 169 | foreach ($arr_key_path as $obj_kpe) { 170 | $arr_anc_path[] = [ 171 | 'kind' => $obj_kpe->getKind(), 172 | 'id' => ($obj_kpe->getIdType() == 'id') ? $obj_kpe->getId() : null, 173 | 'name' => ($obj_kpe->getIdType() == 'name') ? $obj_kpe->getName() : null 174 | ]; 175 | } 176 | $obj_gds_entity->setAncestry($arr_anc_path); 177 | } 178 | 179 | // Return whether or not the Schema matched 180 | return [$obj_gds_entity, $bol_schema_match]; 181 | } 182 | 183 | /** 184 | * Return a gRPC Key from a GDS Entity 185 | * 186 | * @param Entity $obj_gds_entity 187 | * @return Key 188 | */ 189 | public function createGoogleKey(Entity $obj_gds_entity) 190 | { 191 | $obj_key = new Key(); 192 | $obj_key->setPartitionId($this->obj_partition_id); 193 | $path = $this->walkGoogleKeyPathElement([], $obj_gds_entity); 194 | $obj_key->setPath($path); 195 | return $obj_key; 196 | } 197 | 198 | /** 199 | * Recursively walk the Key hierarchy to return a fully mapped key. 200 | * 201 | * @param KeyPathElement[] $path 202 | * @param Entity $obj_gds_entity 203 | * @return KeyPathElement[] 204 | */ 205 | public function walkGoogleKeyPathElement($path, Entity $obj_gds_entity) 206 | { 207 | // Root Key (must be the first in the chain) 208 | $path = $this->prependGoogleKeyPathElement($path, $obj_gds_entity); 209 | 210 | // Add any ancestors 211 | $mix_ancestry = $obj_gds_entity->getAncestry(); 212 | if(is_array($mix_ancestry)) { 213 | // @todo Get direction right! 214 | foreach ($mix_ancestry as $arr_ancestor_element) { 215 | $this->prependGoogleKeyPathElement($path, $arr_ancestor_element); 216 | } 217 | } elseif ($mix_ancestry instanceof Entity) { 218 | // Recursive 219 | $this->walkGoogleKeyPathElement($path, $mix_ancestry); 220 | } 221 | 222 | return $path; 223 | } 224 | 225 | /** 226 | * Prepend KeyPathElement to key hierarchy array. 227 | * 228 | * @param KeyPathElement[] $arr 229 | * @param Entity $obj_gds_entity 230 | * @return KeyPathElement[] 231 | */ 232 | public function prependGoogleKeyPathElement($arr, Entity $obj_gds_entity) 233 | { 234 | $data = [ 235 | 'kind' => $obj_gds_entity->getKind(), 236 | 'id' => $obj_gds_entity->getKeyId(), 237 | 'name' => $obj_gds_entity->getKeyName() 238 | ]; 239 | array_unshift($arr, $this->createGoogleKeyPathElement($data)); 240 | return $arr; 241 | } 242 | 243 | /** 244 | * Create a Google Key Path Element object 245 | * 246 | * @param array $arr_kpe 247 | * @return KeyPathElement 248 | */ 249 | private function createGoogleKeyPathElement(array $arr_kpe) 250 | { 251 | $obj_path_element = new KeyPathElement(); 252 | 253 | $obj_path_element->setKind($arr_kpe['kind']); 254 | isset($arr_kpe['id']) && $obj_path_element->setId($arr_kpe['id']); 255 | isset($arr_kpe['name']) && $obj_path_element->setName($arr_kpe['name']); 256 | 257 | return $obj_path_element; 258 | } 259 | 260 | /** 261 | * Return a gRPC Property Value from a GDS Entity field definition & value 262 | * 263 | * @todo compare with Google API implementation 264 | * 265 | * @param array $arr_field_def 266 | * @param $mix_value 267 | * @return Value 268 | * @throws \Exception 269 | */ 270 | private function configureGooglePropertyValue(array $arr_field_def, $mix_value) 271 | { 272 | $obj_val = new Value(); 273 | // Indexed? 274 | $bol_index = TRUE; 275 | if(isset($arr_field_def['index']) && FALSE === $arr_field_def['index']) { 276 | $bol_index = FALSE; 277 | } 278 | $obj_val->setExcludeFromIndexes(!$bol_index); 279 | 280 | if (null === $mix_value) { 281 | $obj_val->setNullValue(NullValue::NULL_VALUE); 282 | return $obj_val; 283 | } 284 | 285 | // Value 286 | switch ($arr_field_def['type']) { 287 | case Schema::PROPERTY_STRING: 288 | $obj_val->setStringValue((string)$mix_value); 289 | break; 290 | 291 | case Schema::PROPERTY_INTEGER: 292 | $obj_val->setIntegerValue((int)$mix_value); 293 | break; 294 | 295 | case Schema::PROPERTY_DATETIME: 296 | if($mix_value instanceof \DateTimeInterface) { 297 | $obj_dtm = $mix_value; 298 | } else { 299 | $obj_dtm = new \DateTimeImmutable($mix_value); 300 | } 301 | $obj_timestamp = (new Timestamp()) 302 | ->setSeconds($obj_dtm->getTimestamp()) 303 | ->setNanos(1000 * $obj_dtm->format('u')); 304 | $obj_val->setTimestampValue($obj_timestamp); 305 | break; 306 | 307 | case Schema::PROPERTY_DOUBLE: 308 | case Schema::PROPERTY_FLOAT: 309 | $obj_val->setDoubleValue(floatval($mix_value)); 310 | break; 311 | 312 | case Schema::PROPERTY_BOOLEAN: 313 | $obj_val->setBooleanValue((bool)$mix_value); 314 | break; 315 | 316 | case Schema::PROPERTY_GEOPOINT: 317 | $obj_geo = (new LatLng()) 318 | ->setLatitude($mix_value[0]) 319 | ->setLongitude($mix_value[1]); 320 | $obj_val->setGeoPointValue($obj_geo); 321 | break; 322 | 323 | case Schema::PROPERTY_STRING_LIST: 324 | $obj_val->setExcludeFromIndexes(false); // Ensure we only index the values, not the list 325 | $arr_values = []; 326 | foreach ((array)$mix_value as $str) { 327 | $arr_values[] = (new Value()) 328 | ->setStringValue($str) 329 | ->setExcludeFromIndexes(!$bol_index); 330 | } 331 | $obj_val->setArrayValue( 332 | (new \Google\Cloud\Datastore\V1\ArrayValue()) 333 | ->setValues($arr_values) 334 | ); 335 | break; 336 | 337 | default: 338 | throw new \RuntimeException('Unable to process field type: ' . $arr_field_def['type']); 339 | } 340 | 341 | return $obj_val; 342 | } 343 | 344 | /** 345 | * Extract a datetime value 346 | * 347 | * Attempt to retain microsecond precision 348 | * 349 | * @param Value $obj_property 350 | * @return mixed 351 | */ 352 | protected function extractDatetimeValue($obj_property) 353 | { 354 | $obj_dtm = $obj_property->getTimestampValue()->toDateTime(); 355 | $str_default_tz = date_default_timezone_get(); 356 | if (self::TZ_UTC === $str_default_tz || self::TZ_UTC_OFFSET === $str_default_tz) { 357 | return $obj_dtm; 358 | } 359 | return $obj_dtm->setTimezone(new \DateTimeZone($str_default_tz)); 360 | } 361 | 362 | /** 363 | * Extract a String List value 364 | * 365 | * @param Value $obj_property 366 | * @return mixed 367 | */ 368 | protected function extractStringListValue($obj_property) 369 | { 370 | $arr_values = $obj_property->getArrayValue()->getValues(); 371 | if(count($arr_values) > 0) { 372 | $arr = []; 373 | foreach ($arr_values as $obj_val) { 374 | /** @var $obj_val Value */ 375 | $arr[] = $obj_val->getStringValue(); 376 | } 377 | return $arr; 378 | } 379 | return null; 380 | } 381 | 382 | /** 383 | * Extract a Geopoint value (lat/lon pair) 384 | * 385 | * @param Value $obj_property 386 | * @return Geopoint 387 | */ 388 | protected function extractGeopointValue($obj_property) 389 | { 390 | $obj_gp_value = $obj_property->getGeoPointValue(); 391 | return new Geopoint($obj_gp_value->getLatitude(), $obj_gp_value->getLongitude()); 392 | } 393 | 394 | /** 395 | * Extract a single property value from a Property object 396 | * 397 | * Defer any varying data type extractions to child classes 398 | * 399 | * @param $int_type 400 | * @param Value $obj_property 401 | * @return mixed 402 | * @throws \Exception 403 | */ 404 | protected function extractPropertyValue($int_type, $obj_property) 405 | { 406 | switch ($int_type) { 407 | case Schema::PROPERTY_STRING: 408 | return $obj_property->getStringValue(); 409 | 410 | case Schema::PROPERTY_INTEGER: 411 | return $obj_property->getIntegerValue(); 412 | 413 | case Schema::PROPERTY_DATETIME: 414 | return $this->extractDatetimeValue($obj_property); 415 | 416 | case Schema::PROPERTY_DOUBLE: 417 | case Schema::PROPERTY_FLOAT: 418 | return $obj_property->getDoubleValue(); 419 | 420 | case Schema::PROPERTY_BOOLEAN: 421 | return $obj_property->getBooleanValue(); 422 | 423 | case Schema::PROPERTY_GEOPOINT: 424 | return $this->extractGeopointValue($obj_property); 425 | 426 | case Schema::PROPERTY_STRING_LIST: 427 | return $this->extractStringListValue($obj_property); 428 | 429 | case Schema::PROPERTY_DETECT: 430 | return $this->extractAutoDetectValue($obj_property); 431 | 432 | } 433 | throw new \Exception('Unsupported field type: ' . $int_type); 434 | } 435 | 436 | /** 437 | * Auto detect & extract a value 438 | * 439 | * @param Value $obj_property 440 | * @return mixed 441 | * @throws \Exception 442 | * @todo expand auto detect types 443 | * 444 | */ 445 | protected function extractAutoDetectValue($obj_property) 446 | { 447 | switch ( $obj_property->getValueType() ) { 448 | case "string_value": 449 | return $obj_property->getStringValue(); 450 | break; 451 | case "integer_value": 452 | return $obj_property->getIntegerValue(); 453 | break; 454 | case "timestamp_value": 455 | return $this->extractDatetimeValue($obj_property); 456 | break; 457 | case "double_value": 458 | return $obj_property->getDoubleValue(); 459 | break; 460 | case "boolean_value": 461 | return $obj_property->getBooleanValue(); 462 | break; 463 | case "geo_point_value": 464 | return $this->extractGeopointValue($obj_property); 465 | break; 466 | case "array_value": 467 | return $this->extractStringListValue($obj_property); 468 | break; 469 | default: 470 | throw new \Exception('Unsupported field type: ' . $obj_property->getValueType()); 471 | break; 472 | } 473 | return null; 474 | } 475 | } -------------------------------------------------------------------------------- /src/GDS/Gateway/RESTv1.php: -------------------------------------------------------------------------------- 1 | str_dataset_id = $str_project_id; 57 | $this->str_namespace = $str_namespace; 58 | } 59 | 60 | /** 61 | * Use a pre-configured HTTP Client 62 | * 63 | * @param ClientInterface $obj_client 64 | * @return $this 65 | */ 66 | public function setHttpClient(ClientInterface $obj_client) 67 | { 68 | $this->obj_http_client = $obj_client; 69 | return $this; 70 | } 71 | 72 | /** 73 | * Get the current HTTP Client in use 74 | * 75 | * @return ClientInterface 76 | */ 77 | public function getHttpClient() 78 | { 79 | return $this->obj_http_client; 80 | } 81 | 82 | /** 83 | * Lazily initialise the HTTP Client when needed. Once. 84 | * 85 | * @return ClientInterface 86 | */ 87 | protected function httpClient() 88 | { 89 | if (null === $this->obj_http_client) { 90 | $this->obj_http_client = $this->initHttpClient(); 91 | } 92 | return $this->obj_http_client; 93 | } 94 | 95 | /** 96 | * Configure HTTP Client 97 | * 98 | * @return ClientInterface 99 | */ 100 | protected function initHttpClient() 101 | { 102 | // Middleware 103 | $obj_stack = HandlerStack::create(); 104 | 105 | $str_base_url = self::DEFAULT_BASE_URL; 106 | 107 | $str_emulator_url = getenv("DATASTORE_EMULATOR_HOST"); 108 | if (false !== $str_emulator_url) { 109 | $str_base_url = $str_emulator_url; 110 | } else { 111 | $obj_stack->push( 112 | ApplicationDefaultCredentials::getMiddleware(['https://www.googleapis.com/auth/datastore']) 113 | ); 114 | } 115 | 116 | // Create the HTTP client 117 | return new Client([ 118 | 'handler' => $obj_stack, 119 | 'base_url' => $str_base_url, 120 | 'auth' => 'google_auth' // authorize all requests 121 | ]); 122 | } 123 | 124 | /** 125 | * Put an array of Entities into the Datastore. Return any that need AutoIDs 126 | * 127 | * @param \GDS\Entity[] $arr_entities 128 | * @return \GDS\Entity[] 129 | */ 130 | protected function upsert(array $arr_entities) 131 | { 132 | /* @var $arr_auto_id_required \GDS\Entity[] */ 133 | $arr_auto_id_required = []; 134 | 135 | // Keep arrays of mutation types, so we can be more comfortable later with the ID mapping sequence 136 | $arr_inserts = []; 137 | $arr_upserts = []; 138 | 139 | foreach($arr_entities as $obj_gds_entity) { 140 | 141 | // Build a REST object, apply current partition 142 | $obj_rest_entity = $this->createMapper()->mapToGoogle($obj_gds_entity); 143 | $this->applyPartition($obj_rest_entity->key); 144 | 145 | if(null === $obj_gds_entity->getKeyId() && null === $obj_gds_entity->getKeyName()) { 146 | $arr_inserts[] = (object)['insert' => $obj_rest_entity]; 147 | $arr_auto_id_required[] = $obj_gds_entity; // maintain reference to the array of requested auto-ids 148 | } else { 149 | $arr_upserts[] = (object)['upsert' => $obj_rest_entity]; 150 | } 151 | } 152 | 153 | // Build the base request, add the prepared mutations 154 | $obj_request = $this->buildCommitRequest(); 155 | $obj_request->mutations = array_merge($arr_inserts, $arr_upserts); 156 | 157 | // Run 158 | $this->executePostRequest('commit', $obj_request); 159 | 160 | return $arr_auto_id_required; 161 | } 162 | 163 | /** 164 | * Execute a POST request against the API 165 | * 166 | * @param $str_action 167 | * @param null $obj_request_body 168 | */ 169 | private function executePostRequest($str_action, $obj_request_body = null) 170 | { 171 | $arr_options = []; 172 | if(null !== $obj_request_body) { 173 | $arr_options['json'] = $obj_request_body; 174 | } 175 | $str_url = $this->actionUrl($str_action); 176 | $obj_response = $this->executeWithExponentialBackoff( 177 | function () use ($str_url, $arr_options) { 178 | return $this->httpClient()->post($str_url, $arr_options); 179 | }, 180 | \GuzzleHttp\Exception\RequestException::class 181 | ); 182 | $this->obj_last_response = \json_decode((string)$obj_response->getBody()); 183 | } 184 | 185 | /** 186 | * Build a basic commit request (used by upsert, delete) 187 | * 188 | * @return object 189 | */ 190 | private function buildCommitRequest() 191 | { 192 | $obj_request = (object)['mutations' => []]; 193 | 194 | // Transaction at root level, so do not use applyTransaction() 195 | if(null !== $this->str_next_transaction) { 196 | $obj_request->transaction = $this->str_next_transaction; 197 | $obj_request->mode = self::MODE_TRANSACTIONAL; 198 | $this->str_next_transaction = null; 199 | } else { 200 | $obj_request->mode = self::MODE_NON_TRANSACTIONAL; 201 | } 202 | return $obj_request; 203 | } 204 | 205 | /** 206 | * Extract Auto Insert IDs from the last response 207 | * 208 | * https://cloud.google.com/datastore/reference/rest/v1/projects/commit#MutationResult 209 | * 210 | * @return array 211 | */ 212 | protected function extractAutoIDs() 213 | { 214 | $arr_ids = []; 215 | if(isset($this->obj_last_response->mutationResults) && is_array($this->obj_last_response->mutationResults)) { 216 | foreach ($this->obj_last_response->mutationResults as $obj_mutation_result) { 217 | if(isset($obj_mutation_result->key)) { 218 | $obj_path_end = end($obj_mutation_result->key->path); 219 | $arr_ids[] = $obj_path_end->id; 220 | } 221 | } 222 | } 223 | return $arr_ids; 224 | } 225 | 226 | /** 227 | * Delete 1-many entities 228 | * 229 | * @param array $arr_entities 230 | * @return mixed 231 | */ 232 | public function deleteMulti(array $arr_entities) 233 | { 234 | 235 | // Build the base request 236 | $obj_request = $this->buildCommitRequest(); 237 | 238 | // Create JSON keys for each delete mutation 239 | foreach($arr_entities as $obj_gds_entity) { 240 | $obj_rest_key = (object)['path' => $this->createMapper()->buildKeyPath($obj_gds_entity)]; 241 | $this->applyPartition($obj_rest_key); 242 | $obj_request->mutations[] = (object)['delete' => $obj_rest_key]; 243 | } 244 | 245 | // Run 246 | $this->executePostRequest('commit', $obj_request); 247 | 248 | return true; // Still not sure about this... 249 | } 250 | 251 | /** 252 | * Fetch some Entities, based on the supplied GQL and, optionally, parameters 253 | * 254 | * POST /v1/projects/{projectId}:runQuery 255 | * 256 | * @todo Look into using this to avoid unwanted further "fetch" at the end of paginated result: $this->obj_last_response->batch->moreResults == NO_MORE_RESULTS 257 | * 258 | * @param string $str_gql 259 | * @param null|array $arr_params 260 | * @return mixed 261 | */ 262 | public function gql($str_gql, $arr_params = null) 263 | { 264 | 265 | // Build the query 266 | $obj_request = (object)[ 267 | 'gqlQuery' => (object)[ 268 | 'allowLiterals' => true, 269 | 'queryString' => $str_gql 270 | ] 271 | ]; 272 | $this->applyPartition($obj_request); 273 | $this->applyTransaction($obj_request); 274 | if(is_array($arr_params)) { 275 | $this->addParamsToQuery($obj_request->gqlQuery, $arr_params); 276 | } 277 | 278 | // Run 279 | $this->executePostRequest('runQuery', $obj_request); 280 | 281 | // Extract results 282 | $arr_mapped_results = []; 283 | if(isset($this->obj_last_response->batch->entityResults) && is_array($this->obj_last_response->batch->entityResults)) { 284 | $arr_mapped_results = $this->createMapper()->mapFromResults($this->obj_last_response->batch->entityResults); 285 | } 286 | $this->obj_schema = null; // Consume Schema 287 | return $arr_mapped_results; 288 | 289 | } 290 | 291 | /** 292 | * Fetch 1-many Entities, using the Key parts provided 293 | * 294 | * Consumes Schema 295 | * 296 | * @param array $arr_key_parts 297 | * @param $str_setter 298 | * @return mixed 299 | */ 300 | protected function fetchByKeyPart(array $arr_key_parts, $str_setter) 301 | { 302 | 303 | // Build the query 304 | $obj_request = (object)[ 305 | 'keys' => [] 306 | ]; 307 | $this->applyTransaction($obj_request); 308 | 309 | // Add keys 310 | foreach($arr_key_parts as $str_key_part) { 311 | $obj_element = (object)['kind' => $this->obj_schema->getKind()]; 312 | if('setId' === $str_setter) { 313 | $obj_element->id = $str_key_part; 314 | } elseif ('setName' === $str_setter) { 315 | $obj_element->name = $str_key_part; 316 | } 317 | $obj_key = (object)['path' => [$obj_element]]; 318 | $this->applyPartition($obj_key); 319 | $obj_request->keys[] = $obj_key; 320 | } 321 | 322 | // Run 323 | $this->executePostRequest('lookup', $obj_request); 324 | 325 | // Extract results 326 | $arr_mapped_results = []; 327 | if(isset($this->obj_last_response->found) && is_array($this->obj_last_response->found)) { 328 | $arr_mapped_results = $this->createMapper()->mapFromResults($this->obj_last_response->found); 329 | } 330 | $this->obj_schema = null; // Consume Schema 331 | return $arr_mapped_results; 332 | } 333 | 334 | /** 335 | * Apply project and namespace to a query 336 | * 337 | * @param \stdClass $obj_request 338 | * @return \stdClass 339 | */ 340 | private function applyPartition(\stdClass $obj_request) { 341 | $obj_request->partitionId = (object)[ 342 | 'projectId' => $this->str_dataset_id 343 | ]; 344 | if(null !== $this->str_namespace) { 345 | $obj_request->partitionId->namespaceId = $this->str_namespace; 346 | } 347 | return $obj_request; 348 | } 349 | 350 | /** 351 | * If we are in a transaction, apply it to the request object 352 | * 353 | * @todo Deal with read consistency 354 | * 355 | * @param $obj_request 356 | * @return mixed 357 | */ 358 | private function applyTransaction(\stdClass $obj_request) { 359 | if(null !== $this->str_next_transaction) { 360 | $obj_request->readOptions = (object)[ 361 | // 'readConsistency' => $options->getReadConsistency(), 362 | "transaction" => $this->str_next_transaction 363 | ]; 364 | $this->str_next_transaction = null; 365 | } 366 | return $obj_request; 367 | } 368 | 369 | /** 370 | * Add Parameters to a GQL Query object 371 | * 372 | * @param \stdClass $obj_query 373 | * @param array $arr_params 374 | */ 375 | private function addParamsToQuery(\stdClass $obj_query, array $arr_params) 376 | { 377 | if(count($arr_params) > 0) { 378 | $obj_bindings = new \stdClass(); 379 | foreach ($arr_params as $str_name => $mix_value) { 380 | if('startCursor' == $str_name || 'endCursor' == $str_name) { 381 | $obj_bindings->{$str_name} = (object)['cursor' => (string)$mix_value]; 382 | } else { 383 | $obj_bindings->{$str_name} = (object)['value' => $this->buildQueryParamValue($mix_value)]; 384 | } 385 | } 386 | $obj_query->namedBindings = $obj_bindings; 387 | } 388 | } 389 | 390 | /** 391 | * Build a JSON representation of a value 392 | * 393 | * @param $mix_value 394 | * @return \stdClass 395 | */ 396 | private function buildQueryParamValue($mix_value) 397 | { 398 | $obj_val = new \stdClass(); 399 | $str_type = gettype($mix_value); 400 | switch($str_type) { 401 | case 'boolean': 402 | $obj_val->booleanValue = $mix_value; 403 | break; 404 | 405 | case 'integer': 406 | $obj_val->integerValue = $mix_value; 407 | break; 408 | 409 | case 'double': 410 | $obj_val->doubleValue = $mix_value; 411 | break; 412 | 413 | case 'string': 414 | $obj_val->stringValue = $mix_value; 415 | break; 416 | 417 | case 'array': 418 | throw new \InvalidArgumentException('Unexpected array parameter'); 419 | 420 | case 'object': 421 | $this->configureObjectValueParamForQuery($obj_val, $mix_value); 422 | break; 423 | 424 | case 'NULL': 425 | $obj_val->nullValue = null; 426 | break; 427 | 428 | case 'resource': 429 | case 'unknown type': 430 | default: 431 | throw new \InvalidArgumentException('Unsupported parameter type: ' . $str_type); 432 | } 433 | return $obj_val; 434 | } 435 | 436 | /** 437 | * Configure a Value parameter, based on the supplied object-type value 438 | * 439 | * @param object $obj_val 440 | * @param object $mix_value 441 | */ 442 | protected function configureObjectValueParamForQuery($obj_val, $mix_value) 443 | { 444 | if($mix_value instanceof Entity) { 445 | /** @var Entity $mix_value */ 446 | $obj_val->keyValue = $this->applyPartition((object)['path' => $this->createMapper()->buildKeyPath($mix_value)]); 447 | } elseif ($mix_value instanceof \DateTimeInterface) { 448 | $obj_val->timestampValue = $mix_value->format(\GDS\Mapper\RESTv1::DATETIME_FORMAT_ZULU); 449 | } elseif (method_exists($mix_value, '__toString')) { 450 | $obj_val->stringValue = $mix_value->__toString(); 451 | } else { 452 | throw new \InvalidArgumentException('Unexpected, non-string-able object parameter: ' . get_class($mix_value)); 453 | } 454 | } 455 | 456 | /** 457 | * Get the end cursor from the last response 458 | * 459 | * @return mixed 460 | */ 461 | public function getEndCursor() 462 | { 463 | if(isset($this->obj_last_response->batch) && isset($this->obj_last_response->batch->endCursor)) { 464 | return $this->obj_last_response->batch->endCursor; 465 | } 466 | return null; 467 | } 468 | 469 | /** 470 | * Create a mapper that's right for this Gateway 471 | * 472 | * @return \GDS\Mapper\RESTv1 473 | */ 474 | protected function createMapper() 475 | { 476 | return (new \GDS\Mapper\RESTv1())->setSchema($this->obj_schema); 477 | } 478 | 479 | /** 480 | * Start a transaction 481 | * 482 | * POST /v1/projects/{projectId}:beginTransaction 483 | * 484 | * @param bool $bol_cross_group 485 | * @return null 486 | * @throws \Exception 487 | */ 488 | public function beginTransaction($bol_cross_group = FALSE) 489 | { 490 | if($bol_cross_group) { 491 | throw new \Exception("Cross group transactions not supported over REST API v1"); 492 | } 493 | $this->executePostRequest('beginTransaction'); 494 | if(isset($this->obj_last_response->transaction)) { 495 | return $this->obj_last_response->transaction; 496 | } 497 | return null; 498 | } 499 | 500 | /** 501 | * Get the base url from the client object. 502 | * 503 | * Note: If for some reason the client's base URL is not set then we will return the default endpoint. 504 | * 505 | * @return string 506 | */ 507 | protected function getBaseUrl() { 508 | $str_base_url = $this->httpClient()->getConfig(self::CONFIG_CLIENT_BASE_URL); 509 | if (!empty($str_base_url)) { 510 | return $str_base_url; 511 | } 512 | 513 | return self::DEFAULT_BASE_URL; 514 | } 515 | 516 | /** 517 | * Build a URL for a Datastore action 518 | * 519 | * @param $str_action 520 | * @return string 521 | */ 522 | private function actionUrl($str_action) 523 | { 524 | return $this->getBaseUrl() . '/v1/projects/' . $this->str_dataset_id . ':' . $str_action; 525 | } 526 | 527 | } 528 | -------------------------------------------------------------------------------- /src/GDS/Gateway/GRPCv1.php: -------------------------------------------------------------------------------- 1 | 43 | * 44 | * @author Samuel Melrose 45 | * @author Tom Walder 46 | * @package GDS\Gateway 47 | */ 48 | class GRPCv1 extends \GDS\Gateway 49 | { 50 | // https://cloud.google.com/datastore/docs/concepts/errors 51 | // https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto 52 | const RETRY_ERROR_CODES = [ 53 | Code::UNKNOWN, 54 | Code::ABORTED, 55 | Code::DEADLINE_EXCEEDED, 56 | Code::RESOURCE_EXHAUSTED, 57 | Code::UNAVAILABLE, 58 | Code::INTERNAL, 59 | ]; 60 | 61 | const RETRY_ONCE_CODES = [ 62 | Code::INTERNAL, 63 | ]; 64 | 65 | /** 66 | * Cloud Datastore (gRPC & REST) Client 67 | */ 68 | protected static $obj_datastore_client; 69 | 70 | /** 71 | * Set up the dataset and optional namespace, 72 | * plus the gRPC client. 73 | * 74 | * @param null|string $str_dataset 75 | * @param null|string $str_namespace 76 | * @throws \Exception 77 | */ 78 | public function __construct($str_dataset = null, $str_namespace = null) 79 | { 80 | if(null === $str_dataset) { 81 | $str_dataset = Gateway::determineProjectId(); 82 | } 83 | $this->str_dataset_id = $str_dataset; 84 | $this->str_namespace = $str_namespace; 85 | 86 | // Build the Datastore client 87 | if (!(self::$obj_datastore_client instanceof DatastoreClient)) { 88 | $arr_options = []; 89 | if (!extension_loaded('grpc')) { 90 | $arr_options = ['transport' => 'grpc-fallback']; // This is Protocol buffers over HTTP 1.1 91 | } 92 | self::$obj_datastore_client = new DatastoreClient($arr_options); 93 | } 94 | } 95 | 96 | /** 97 | * Get dataset and namespace ("partition") object 98 | * 99 | * Usually applied to a Key or RunQueryRequest 100 | * 101 | * @return PartitionId 102 | */ 103 | private function createPartitionId() 104 | { 105 | $obj_partition_id = (new PartitionId()) 106 | ->setProjectId($this->str_dataset_id); 107 | if ($this->str_namespace !== null) { 108 | $obj_partition_id->setNamespaceId($this->str_namespace); 109 | } 110 | return $obj_partition_id; 111 | } 112 | 113 | /** 114 | * Execute a method against the Datastore client. 115 | * 116 | * Prepend projectId as first parameter automatically. 117 | * 118 | * @param string $str_method 119 | * @param mixed[] $args 120 | * @return mixed 121 | * @throws \Exception 122 | */ 123 | private function execute(string $str_method, array $args) { 124 | array_unshift($args, $this->str_dataset_id); 125 | return $this->executeWithExponentialBackoff( 126 | function () use ($str_method, $args) { 127 | $this->obj_last_response = call_user_func_array([self::$obj_datastore_client, $str_method], $args); 128 | return $this->obj_last_response; 129 | }, 130 | ApiException::class, 131 | [$this, 'resolveExecuteException'] 132 | ); 133 | } 134 | 135 | /** 136 | * Wrap the somewhat murky ApiException into something more useful 137 | * 138 | * https://cloud.google.com/datastore/docs/concepts/errors 139 | * 140 | * @param ApiException $obj_exception 141 | * @return \Exception 142 | */ 143 | protected function resolveExecuteException(ApiException $obj_exception): \Exception 144 | { 145 | $this->obj_last_response = null; 146 | if (Code::ABORTED === $obj_exception->getCode() || 147 | false !== strpos($obj_exception->getMessage(), 'too much contention') || 148 | false !== strpos($obj_exception->getMessage(), 'Concurrency')) { 149 | // LIVE: "too much contention on these datastore entities. please try again." 150 | // LOCAL : "Concurrency exception." 151 | return new Contention('Datastore contention', 409, $obj_exception); 152 | } 153 | return $obj_exception; 154 | } 155 | 156 | /** 157 | * Convert a RepeatedField to a standard array, 158 | * as it isn't compatible with the usual array functions. 159 | * 160 | * @param RepeatedField $obj_repeats 161 | * @return array 162 | */ 163 | public function convertRepeatedFieldToArray(RepeatedField $obj_repeats) 164 | { 165 | $arr_values = []; 166 | foreach ($obj_repeats as $obj_value) { 167 | $arr_values[] = $obj_value; 168 | } 169 | return $arr_values; 170 | } 171 | 172 | /** 173 | * Fetch 1-many Entities, using the Key parts provided 174 | * 175 | * @param array $arr_key_parts 176 | * @param string $str_setter 177 | * @return \GDS\Entity[]|null 178 | * @throws \Exception 179 | */ 180 | protected function fetchByKeyPart(array $arr_key_parts, $str_setter) 181 | { 182 | $keys = []; 183 | $partitionId = $this->createPartitionId(); 184 | 185 | foreach($arr_key_parts as $mix_key_part) { 186 | $obj_key = new Key(); 187 | $obj_key->setPartitionId($partitionId); 188 | 189 | $obj_kpe = new KeyPathElement(); 190 | $obj_kpe->setKind($this->obj_schema->getKind()); 191 | $obj_kpe->$str_setter($mix_key_part); 192 | 193 | $obj_key->setPath([$obj_kpe]); 194 | 195 | $keys[] = $obj_key; 196 | } 197 | 198 | /** @var LookupResponse $obj_response */ 199 | $obj_response = $this->execute('lookup', [$keys, ['readOptions' => $this->getReadOptions()]]); 200 | $arr_mapped_results = $this->createMapper() 201 | ->mapFromResults( 202 | $this->convertRepeatedFieldToArray( 203 | $obj_response->getFound() 204 | ) 205 | ); 206 | 207 | $this->obj_schema = null; // Consume Schema 208 | 209 | return $arr_mapped_results; 210 | } 211 | 212 | /** 213 | * Put an array of Entities into the Datastore. Return any that need AutoIDs 214 | * 215 | * @todo Validate support for per-entity Schemas 216 | * 217 | * @param \GDS\Entity[] $arr_entities 218 | * @return \GDS\Entity[] 219 | * @throws \Exception 220 | */ 221 | public function upsert(array $arr_entities) 222 | { 223 | $arr_mutations = []; 224 | $arr_auto_id_required = []; 225 | 226 | foreach($arr_entities as $obj_gds_entity) { 227 | if(null === $obj_gds_entity->getKeyId() && null === $obj_gds_entity->getKeyName()) { 228 | $arr_auto_id_required[] = $obj_gds_entity; // maintain reference to the array of requested auto-ids 229 | } 230 | $obj_entity = new GRPC_Entity(); 231 | $this->determineMapper($obj_gds_entity)->mapToGoogle($obj_gds_entity, $obj_entity); 232 | $arr_mutations[] = (new Mutation())->setUpsert($obj_entity); 233 | } 234 | 235 | $arr_options = []; 236 | if(null === $this->str_next_transaction) { 237 | $int_mode = Mode::NON_TRANSACTIONAL; 238 | } else { 239 | $int_mode = Mode::TRANSACTIONAL; 240 | $arr_options['transaction'] = $this->getTransaction(); 241 | } 242 | 243 | $this->execute('commit', [$int_mode, $arr_mutations, $arr_options]); 244 | 245 | return $arr_auto_id_required; 246 | } 247 | 248 | /** 249 | * Delete 1 or many entities, using their Keys 250 | * 251 | * Consumes Schema 252 | * 253 | * @todo Determine success. Not 100% how to do this from the response yet. 254 | * 255 | * @todo Tests... 256 | * 257 | * @param array $arr_entities 258 | * @return bool 259 | * @throws \Exception 260 | */ 261 | public function deleteMulti(array $arr_entities) 262 | { 263 | $obj_mapper = $this->createMapper(); 264 | // $partitionId = $this->createPartitionId(); 265 | $arr_mutations = []; 266 | 267 | foreach($arr_entities as $obj_gds_entity) { 268 | $obj_key = $obj_mapper->createGoogleKey($obj_gds_entity); 269 | $arr_mutations[] = (new Mutation())->setDelete($obj_key); 270 | } 271 | 272 | // @todo withTransaction??? 273 | $options = []; 274 | if(null === $this->str_next_transaction) { 275 | $int_mode = Mode::NON_TRANSACTIONAL; 276 | } else { 277 | $int_mode = Mode::TRANSACTIONAL; 278 | $options['transaction'] = $this->getTransaction(); 279 | } 280 | 281 | $this->execute('commit', [$int_mode, $arr_mutations, $options]); 282 | $this->obj_schema = null; 283 | return TRUE; // really? 284 | } 285 | 286 | /** 287 | * Fetch some Entities, based on the supplied GQL and, optionally, parameters 288 | * 289 | * @param string $str_gql 290 | * @param array|null $arr_params 291 | * @return \GDS\Entity[]|null 292 | * @throws \Exception 293 | */ 294 | public function gql($str_gql, $arr_params = null) 295 | { 296 | $obj_read_options = $this->getReadOptions(); 297 | 298 | $obj_gql_query = (new GqlQuery()) 299 | ->setAllowLiterals(true) 300 | ->setQueryString($str_gql); 301 | 302 | if(null !== $arr_params) { 303 | $this->addParamsToQuery($obj_gql_query, $arr_params); 304 | } 305 | 306 | $obj_gql_response = $this->execute( 307 | 'runQuery', 308 | [ 309 | $this->createPartitionId(), 310 | [ 311 | 'readOptions' => $obj_read_options, 312 | 'gqlQuery' => $obj_gql_query 313 | ] 314 | ] 315 | ); 316 | 317 | $obj_results = $obj_gql_response->getBatch()->getEntityResults(); 318 | $arr_mapped_results = $this->createMapper() 319 | ->mapFromResults( 320 | $this->convertRepeatedFieldToArray($obj_results) 321 | ); 322 | $this->obj_schema = null; // Consume Schema 323 | return $arr_mapped_results; 324 | } 325 | 326 | /** 327 | * Begin a transaction 328 | * 329 | * @todo Evaluate cross-request transactions [setCrossRequest] 330 | * 331 | * @param bool $bol_cross_group 332 | * @return string|null 333 | * @throws \Exception 334 | */ 335 | public function beginTransaction($bol_cross_group = FALSE) 336 | { 337 | if($bol_cross_group) { 338 | // No longer supported?? 339 | // $obj_request->setCrossGroup(TRUE); 340 | } 341 | $obj_response = $this->execute('beginTransaction', []); 342 | return $obj_response->getTransaction(); 343 | } 344 | 345 | /** 346 | * Extract Auto Insert IDs from the last response 347 | * 348 | * @return array 349 | */ 350 | protected function extractAutoIDs() 351 | { 352 | $arr_ids = []; 353 | foreach($this->obj_last_response->getMutationResults() as $obj_list) { 354 | /** @var \Google\Cloud\Datastore\V1\MutationResult $obj_list */ 355 | $obj_key = $obj_list->getKey(); 356 | if ($obj_key !== null) { 357 | /** @var \Google\Protobuf\Internal\RepeatedField $obj_key_path */ 358 | $obj_key_path = $obj_key->getPath(); 359 | $int_last_index = count($obj_key_path) - 1; 360 | $obj_path_end = $obj_key_path[$int_last_index]; 361 | $arr_ids[] = $obj_path_end->getId(); 362 | } 363 | } 364 | return $arr_ids; 365 | } 366 | 367 | /** 368 | * Get the transaction to apply to an object 369 | * 370 | * @return mixed 371 | */ 372 | private function getTransaction() 373 | { 374 | $obj = $this->str_next_transaction; 375 | $this->str_next_transaction = null; 376 | return $obj; 377 | } 378 | 379 | /** 380 | * Get a ReadOptions object, containing transaction info. 381 | * 382 | * @return null|ReadOptions 383 | */ 384 | private function getReadOptions() 385 | { 386 | $obj = null; 387 | if(null !== $this->str_next_transaction) { 388 | $obj = new ReadOptions(); 389 | $obj->setTransaction($this->getTransaction()); 390 | } 391 | return $obj; 392 | } 393 | 394 | /** 395 | * Add Parameters to a GQL Query object 396 | * 397 | * @param GqlQuery $obj_query 398 | * @param array $arr_params 399 | */ 400 | private function addParamsToQuery(GqlQuery $obj_query, array $arr_params) 401 | { 402 | if(count($arr_params) > 0) { 403 | $namedArgs = []; 404 | foreach ($arr_params as $str_name => $mix_value) { 405 | $obj_arg = new GqlQueryParameter(); 406 | if ('startCursor' == $str_name) { 407 | $obj_arg->setCursor($mix_value); 408 | } else { 409 | $obj_val = new Value(); 410 | $this->configureValueParamForQuery($obj_val, $mix_value); 411 | $obj_arg->setValue($obj_val); 412 | } 413 | $namedArgs[$str_name] = $obj_arg; 414 | } 415 | $obj_query->setNamedBindings($namedArgs); 416 | } 417 | } 418 | 419 | /** 420 | * Part of our "add parameters to query" sequence. 421 | * 422 | * @param Value $obj_val 423 | * @param mixed $mix_value 424 | * @return Value 425 | */ 426 | protected function configureValueParamForQuery($obj_val, $mix_value) 427 | { 428 | $str_type = gettype($mix_value); 429 | switch($str_type) { 430 | case 'boolean': 431 | $obj_val->setBooleanValue($mix_value); 432 | break; 433 | 434 | case 'integer': 435 | $obj_val->setIntegerValue($mix_value); 436 | break; 437 | 438 | case 'double': 439 | $obj_val->setDoubleValue($mix_value); 440 | break; 441 | 442 | case 'string': 443 | $obj_val->setStringValue($mix_value); 444 | break; 445 | 446 | case 'array': 447 | throw new \InvalidArgumentException('Unexpected array parameter'); 448 | 449 | case 'object': 450 | $this->configureObjectValueParamForQuery($obj_val, $mix_value); 451 | break; 452 | 453 | case 'NULL': 454 | $obj_val->setNullValue(null); 455 | break; 456 | 457 | case 'resource': 458 | case 'unknown type': 459 | default: 460 | throw new \InvalidArgumentException('Unsupported parameter type: ' . $str_type); 461 | } 462 | return $obj_val; 463 | } 464 | 465 | /** 466 | * Configure a Value parameter, based on the supplied object-type value 467 | * 468 | * @todo Re-use one Mapper instance 469 | * 470 | * @param Value $obj_val 471 | * @param object $mix_value 472 | */ 473 | protected function configureObjectValueParamForQuery($obj_val, $mix_value) 474 | { 475 | if($mix_value instanceof Entity) { 476 | $obj_key = $this->createMapper()->createGoogleKey($mix_value); 477 | $obj_val->setKeyValue($obj_key); 478 | } elseif ($mix_value instanceof \DateTimeInterface) { 479 | $obj_timestamp = (new Timestamp()) 480 | ->setSeconds($mix_value->getTimestamp()) 481 | ->setNanos(1000 * $mix_value->format('u')); 482 | $obj_val->setTimestampValue($obj_timestamp); 483 | } elseif (method_exists($mix_value, '__toString')) { 484 | $obj_val->setStringValue($mix_value->__toString()); 485 | } else { 486 | throw new \InvalidArgumentException('Unexpected, non-string-able object parameter: ' . get_class($mix_value)); 487 | } 488 | } 489 | 490 | /** 491 | * Get the end cursor from the last response 492 | */ 493 | public function getEndCursor() 494 | { 495 | return $this->obj_last_response->getBatch()->getEndCursor(); 496 | } 497 | 498 | /** 499 | * Create a mapper that's right for this Gateway 500 | * 501 | * @return \GDS\Mapper\GRPCv1 502 | */ 503 | protected function createMapper() 504 | { 505 | return (new \GDS\Mapper\GRPCv1()) 506 | ->setSchema($this->obj_schema) 507 | ->setPartitionId( 508 | $this->createPartitionId() 509 | ); 510 | } 511 | } -------------------------------------------------------------------------------- /src/GDS/Mapper/RESTv1.php: -------------------------------------------------------------------------------- 1 | stringValue)) { 46 | return $obj_property->stringValue; 47 | } 48 | if(isset($obj_property->integerValue)) { 49 | return $obj_property->integerValue; 50 | } 51 | if(isset($obj_property->doubleValue)) { 52 | return $obj_property->doubleValue; 53 | } 54 | if(isset($obj_property->booleanValue)) { 55 | return $obj_property->booleanValue; 56 | } 57 | if(isset($obj_property->timestampValue)) { 58 | return $this->extractDatetimeValue($obj_property); 59 | } 60 | if(isset($obj_property->geoPointValue)) { 61 | return $this->extractGeopointValue($obj_property); 62 | } 63 | if(isset($obj_property->arrayValue)) { 64 | return $this->extractStringListValue($obj_property); 65 | } 66 | if(property_exists($obj_property, 'nullValue')) { 67 | return null; 68 | } 69 | } 70 | 71 | /** 72 | * Extract a datetime value 73 | * 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() 80 | * 81 | * @param $obj_property 82 | * @return mixed 83 | */ 84 | 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 97 | { 98 | $arr_matches = []; 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); 105 | } 106 | return (new \DateTime($str_datetime, new \DateTimeZone(self::TZ_UTC))) 107 | ->setTimezone(new \DateTimeZone($str_default_tz)); 108 | } 109 | 110 | /** 111 | * Extract a String List value 112 | * 113 | * @param $obj_property 114 | * @throws \Exception 115 | * @return array 116 | */ 117 | protected function extractStringListValue($obj_property) 118 | { 119 | $arr_values = []; 120 | 121 | if (!isset($obj_property->arrayValue->values)) { 122 | return $arr_values; 123 | } 124 | 125 | foreach((array)$obj_property->arrayValue->values as $obj_value) { 126 | if(isset($obj_value->stringValue)) { 127 | $arr_values[] = $obj_value->stringValue; 128 | } 129 | } 130 | return $arr_values; 131 | } 132 | 133 | /** 134 | * Extract a Geopoint value 135 | * 136 | * @param $obj_property 137 | * @return Geopoint 138 | */ 139 | protected function extractGeopointValue($obj_property) 140 | { 141 | return new Geopoint($obj_property->geoPointValue->latitude, $obj_property->geoPointValue->longitude); 142 | } 143 | 144 | /** 145 | * Map a single result out of the Raw response data array FROM Google TO a GDS Entity 146 | * 147 | * @param object $obj_result 148 | * @return Entity 149 | * @throws \Exception 150 | */ 151 | public function mapOneFromResult($obj_result) 152 | { 153 | // Key & Ancestry 154 | list($obj_gds_entity, $bol_schema_match) = $this->createEntityWithKey($obj_result); 155 | /** @var Entity $obj_gds_entity */ 156 | 157 | // Properties 158 | if(isset($obj_result->entity->properties)) { 159 | $arr_property_definitions = $this->obj_schema->getProperties(); 160 | foreach ($obj_result->entity->properties as $str_field => $obj_property) { 161 | if ($bol_schema_match && isset($arr_property_definitions[$str_field])) { 162 | $obj_gds_entity->__set($str_field, $this->extractPropertyValue($arr_property_definitions[$str_field]['type'], $obj_property)); 163 | } else { 164 | $obj_gds_entity->__set($str_field, $this->extractPropertyValue(Schema::PROPERTY_DETECT, $obj_property)); 165 | } 166 | } 167 | } 168 | 169 | // Done 170 | return $obj_gds_entity; 171 | } 172 | 173 | /** 174 | * Create & populate a GDS\Entity with key data 175 | * 176 | * @param \stdClass $obj_result 177 | * @return array 178 | */ 179 | private function createEntityWithKey(\stdClass $obj_result) 180 | { 181 | if(isset($obj_result->entity->key->path)) { 182 | $arr_path = $obj_result->entity->key->path; 183 | 184 | // Key for 'self' (the last part of the KEY PATH) 185 | $obj_path_end = array_pop($arr_path); 186 | 187 | // Kind 188 | if(isset($obj_path_end->kind)) { 189 | if($obj_path_end->kind == $this->obj_schema->getKind()) { 190 | $bol_schema_match = TRUE; 191 | $obj_gds_entity = $this->obj_schema->createEntity(); 192 | } else { 193 | // Attempt to handle a non-schema-match 194 | $bol_schema_match = FALSE; 195 | $obj_gds_entity = (new \GDS\Entity())->setKind($obj_path_end->kind); 196 | } 197 | } else { 198 | throw new \RuntimeException("No Kind for end(path) for Entity?"); 199 | } 200 | 201 | // ID or Name 202 | if(isset($obj_path_end->id)) { 203 | $obj_gds_entity->setKeyId($obj_path_end->id); 204 | } elseif (isset($obj_path_end->name)) { 205 | $obj_gds_entity->setKeyName($obj_path_end->name); 206 | } else { 207 | throw new \RuntimeException("No KeyID or KeyName for Entity?"); 208 | } 209 | 210 | // Ancestors? 211 | $int_path_elements = count($arr_path); 212 | if($int_path_elements > 0) { 213 | $arr_anc_path = []; 214 | foreach ($arr_path as $obj_kpe) { 215 | $arr_anc_path[] = [ 216 | 'kind' => $obj_kpe->kind, 217 | 'id' => isset($obj_kpe->id) ? $obj_kpe->id : null, 218 | 'name' => isset($obj_kpe->name) ? $obj_kpe->name : null 219 | ]; 220 | } 221 | $obj_gds_entity->setAncestry($arr_anc_path); 222 | } 223 | } else { 224 | throw new \RuntimeException("No path for Entity Key?"); 225 | } 226 | 227 | // Return whether or not the Schema matched 228 | return [$obj_gds_entity, $bol_schema_match]; 229 | } 230 | 231 | /** 232 | * Extract a single property value from a Property object 233 | * 234 | * Defer any varying data type extractions to child classes 235 | * 236 | * @param $int_type 237 | * @param object $obj_property 238 | * @return mixed 239 | * @throws \Exception 240 | */ 241 | protected function extractPropertyValue($int_type, $obj_property) 242 | { 243 | switch ($int_type) { 244 | case Schema::PROPERTY_STRING: 245 | return isset($obj_property->stringValue) ? $obj_property->stringValue : null; 246 | 247 | case Schema::PROPERTY_INTEGER: 248 | return isset($obj_property->integerValue) ? $obj_property->integerValue : null; 249 | 250 | case Schema::PROPERTY_DATETIME: 251 | return isset($obj_property->timestampValue) ? $this->extractDatetimeValue($obj_property) : null; 252 | 253 | case Schema::PROPERTY_DOUBLE: 254 | case Schema::PROPERTY_FLOAT: 255 | return isset($obj_property->doubleValue) ? $obj_property->doubleValue : null; 256 | 257 | case Schema::PROPERTY_BOOLEAN: 258 | return isset($obj_property->booleanValue) ? $obj_property->booleanValue : null; 259 | 260 | case Schema::PROPERTY_GEOPOINT: 261 | return isset($obj_property->geoPointValue) ? $this->extractGeopointValue($obj_property) : null; 262 | 263 | case Schema::PROPERTY_STRING_LIST: 264 | return $this->extractStringListValue($obj_property); 265 | 266 | case Schema::PROPERTY_DETECT: 267 | return $this->extractAutoDetectValue($obj_property); 268 | 269 | } 270 | throw new \Exception('Unsupported field type: ' . $int_type); 271 | } 272 | 273 | /** 274 | * Create a REST representation of a GDS entity 275 | * 276 | * https://cloud.google.com/datastore/reference/rest/v1/Entity 277 | * 278 | * @param Entity $obj_gds_entity 279 | * @return \stdClass 280 | */ 281 | public function mapToGoogle(Entity $obj_gds_entity) 282 | { 283 | 284 | // Base entity with key (partition applied later) 285 | $obj_rest_entity = (object)[ 286 | 'key' => (object)['path' => $this->buildKeyPath($obj_gds_entity)], 287 | 'properties' => (object)[] 288 | ]; 289 | 290 | // Properties 291 | $arr_field_defs = $this->bestEffortFieldDefs($obj_gds_entity); 292 | foreach($obj_gds_entity->getData() as $str_field_name => $mix_value) { 293 | if(isset($arr_field_defs[$str_field_name])) { 294 | $obj_rest_entity->properties->{$str_field_name} = $this->createPropertyValue($arr_field_defs[$str_field_name], $mix_value); 295 | } else { 296 | $arr_dynamic_data = $this->determineDynamicType($mix_value); 297 | $obj_rest_entity->properties->{$str_field_name} = $this->createPropertyValue(['type' => $arr_dynamic_data['type'], 'index' => true], $arr_dynamic_data['value']); 298 | } 299 | } 300 | 301 | return $obj_rest_entity; 302 | } 303 | 304 | /** 305 | * Find and return the field definitions (if any) for the Entity 306 | * 307 | * @param Entity $obj_gds_entity 308 | * @return array 309 | */ 310 | private function bestEffortFieldDefs(Entity $obj_gds_entity) 311 | { 312 | if($obj_gds_entity->getSchema() instanceof Schema) { 313 | return $obj_gds_entity->getSchema()->getProperties(); 314 | } 315 | if($this->obj_schema instanceof Schema) { 316 | return $this->obj_schema->getProperties(); 317 | } 318 | return []; 319 | } 320 | 321 | /** 322 | * Create a fully qualified Key path 323 | * 324 | * @equivalent ProtoBuf::configureKey() ? 325 | * 326 | * @param Entity $obj_gds_entity 327 | * @param bool $bol_first_node 328 | * @return array 329 | * @throws \Exception 330 | */ 331 | public function buildKeyPath(Entity $obj_gds_entity, $bol_first_node = true) 332 | { 333 | $str_kind = $obj_gds_entity->getKind(); 334 | if(null === $str_kind) { 335 | if($bol_first_node) { 336 | if($this->obj_schema instanceof Schema) { 337 | $str_kind = $this->obj_schema->getKind(); 338 | } else { 339 | throw new \Exception('Could not build full key path, no Schema set on Mapper and no Kind set on Entity'); 340 | } 341 | } else { 342 | throw new \Exception('Could not build full key path, no Kind set on (nth node) GDS Entity'); 343 | } 344 | } 345 | 346 | // Build the first node in the Key Path from this entity 347 | $arr_full_path = [$this->createKeyPathElement([ 348 | 'kind' => $str_kind, 349 | 'id' => $obj_gds_entity->getKeyId(), 350 | 'name' => $obj_gds_entity->getKeyName() 351 | ])]; 352 | 353 | // Add any ancestors to the Key Path 354 | $mix_ancestry = $obj_gds_entity->getAncestry(); 355 | if(is_array($mix_ancestry)) { 356 | $arr_ancestor_path = []; 357 | foreach($mix_ancestry as $arr_ancestor_element) { 358 | $arr_ancestor_path[] = $this->createKeyPathElement($arr_ancestor_element); 359 | } 360 | $arr_full_path = array_merge($arr_ancestor_path, $arr_full_path); 361 | } elseif ($mix_ancestry instanceof Entity) { 362 | $arr_full_path = array_merge($this->buildKeyPath($mix_ancestry, false), $arr_full_path); 363 | } 364 | return $arr_full_path; 365 | } 366 | 367 | /** 368 | * Create a Key Path Element from array 369 | * 370 | * @param array $arr_kpe 371 | * @return \stdClass 372 | */ 373 | protected function createKeyPathElement(array $arr_kpe) 374 | { 375 | $obj_element = (object)['kind' => $arr_kpe['kind']]; 376 | if(isset($arr_kpe['id'])) { 377 | $obj_element->id = $arr_kpe['id']; 378 | } elseif (isset($arr_kpe['name'])) { 379 | $obj_element->name = $arr_kpe['name']; 380 | } 381 | return $obj_element; 382 | } 383 | 384 | /** 385 | * Create a property object 386 | * 387 | * @todo Compare with parameter value method from REST Gateway 388 | * 389 | * @param array $arr_field_def 390 | * @param $mix_value 391 | * @return mixed 392 | */ 393 | protected function createPropertyValue(array $arr_field_def, $mix_value) 394 | { 395 | $obj_property_value = new \stdClass(); 396 | 397 | // Indexed? 398 | $bol_index = TRUE; 399 | if(isset($arr_field_def['index']) && FALSE === $arr_field_def['index']) { 400 | $bol_index = FALSE; 401 | } 402 | $obj_property_value->excludeFromIndexes = !$bol_index; 403 | 404 | if (null === $mix_value) { 405 | $obj_property_value->nullValue = $mix_value; 406 | return $obj_property_value; 407 | } 408 | 409 | switch ($arr_field_def['type']) { 410 | case Schema::PROPERTY_STRING: 411 | $obj_property_value->stringValue = (string)$mix_value; 412 | break; 413 | 414 | case Schema::PROPERTY_INTEGER: 415 | $obj_property_value->integerValue = $mix_value; 416 | break; 417 | 418 | case Schema::PROPERTY_DATETIME: 419 | if($mix_value instanceof \DateTimeInterface) { 420 | $obj_dtm = $mix_value; 421 | } else { 422 | $obj_dtm = new \DateTimeImmutable($mix_value); 423 | } 424 | // A timestamp in RFC3339 UTC "Zulu" format, accurate to nanoseconds. Example: "2014-10-02T15:01:23.045123456Z". 425 | $obj_property_value->timestampValue = \DateTime::createFromFormat( 426 | self::DATETIME_FORMAT_UDOTU, 427 | $obj_dtm->format(self::DATETIME_FORMAT_UDOTU) 428 | )->format(self::DATETIME_FORMAT_ZULU); 429 | break; 430 | 431 | case Schema::PROPERTY_DOUBLE: 432 | case Schema::PROPERTY_FLOAT: 433 | $obj_property_value->doubleValue = floatval($mix_value); 434 | break; 435 | 436 | case Schema::PROPERTY_BOOLEAN: 437 | $obj_property_value->booleanValue = (bool)$mix_value; 438 | break; 439 | 440 | case Schema::PROPERTY_GEOPOINT: 441 | if($mix_value instanceof Geopoint) { 442 | /** @var Geopoint $mix_value */ 443 | $obj_property_value->geoPointValue = (object)[ 444 | "latitude" => $mix_value->getLatitude(), 445 | "longitude" => $mix_value->getLongitude() 446 | ]; 447 | } elseif (is_array($mix_value)) { 448 | $obj_property_value->geoPointValue = (object)[ 449 | "latitude" => $mix_value[0], 450 | "longitude" => $mix_value[1] 451 | ]; 452 | } else { 453 | throw new \InvalidArgumentException('Geopoint property data not supported: ' . gettype($mix_value)); 454 | } 455 | break; 456 | 457 | case Schema::PROPERTY_STRING_LIST: 458 | // Docs: "A Value instance that sets field arrayValue must not set fields meaning or excludeFromIndexes." 459 | unset($obj_property_value->excludeFromIndexes); 460 | // As we cannot set excludeFromIndexes on the property itself, set it on each value in the array 461 | $arr_values = []; 462 | foreach ((array)$mix_value as $str) { 463 | $obj_value = (object)['stringValue' => $str]; 464 | $obj_value->excludeFromIndexes = !$bol_index; 465 | $arr_values[] = $obj_value; 466 | } 467 | $obj_property_value->arrayValue = (object)['values' => $arr_values]; 468 | break; 469 | 470 | default: 471 | throw new \RuntimeException('Unable to process field type: ' . $arr_field_def['type']); 472 | } 473 | return $obj_property_value; 474 | } 475 | } 476 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://api.travis-ci.org/tomwalder/php-gds.svg)](https://travis-ci.org/tomwalder/php-gds) 2 | [![Coverage Status](https://coveralls.io/repos/github/tomwalder/php-gds/badge.svg?branch=master)](https://coveralls.io/github/tomwalder/php-gds?branch=master) 3 | 4 | # Google Cloud Datastore Library for PHP # 5 | 6 | [Google Cloud Datastore](https://cloud.google.com/datastore/) is a great NoSQL solution (hosted, scalable, free up to a point), but it can be tricky (i.e. there's lots of code glue needed) to get even the "Hello World" of data persistence up and running in PHP. 7 | 8 | This library is intended to make it easier for you to get started with and to use Datastore in your applications. 9 | 10 | ## Quick Start ## 11 | ```bash 12 | composer require "tomwalder/php-gds:^6.1" 13 | ``` 14 | ```php 15 | // Build a new entity 16 | $obj_book = new GDS\Entity(); 17 | $obj_book->title = 'Romeo and Juliet'; 18 | $obj_book->author = 'William Shakespeare'; 19 | $obj_book->isbn = '1840224339'; 20 | 21 | // Write it to Datastore 22 | $obj_store = new GDS\Store('Book'); 23 | $obj_store->upsert($obj_book); 24 | 25 | // Fetch all books 26 | foreach($obj_store->fetchAll() as $obj_book) { 27 | echo "Title: {$obj_book->title}, ISBN: {$obj_book->isbn}
", PHP_EOL; 28 | } 29 | ``` 30 | 31 | ## New in Version 6.1 ## 32 | 33 | Support for automated exponential backoff for some types of errors. See documentation here: 34 | https://cloud.google.com/datastore/docs/concepts/errors 35 | 36 | To enable: 37 | ```php 38 | \GDS\Gateway::exponentialBackoff(true); 39 | ``` 40 | 41 | Version `6.0.0` introduced better (but different) support for `NULL` values. 42 | 43 | ## New in Version 5.0 ## 44 | 45 | **As of version 5 (May 2021), this library provides support for** 46 | 47 | * PHP 7 second-generation App Engine runtimes - using the REST API by default 48 | * PHP 7 "anywhere" (e.g. Google Compute Engine, Cloud Run, GKE) - using REST or gRPC 49 | 50 | **Key features removed from version 5 onwards** 51 | 52 | * PHP 5 support 53 | * Support for the legacy "Protocol Buffer" API built into first-generation App Engine runtimes 54 | 55 | If you need to continue running applications on that infrastructure, stick to version 4.x or earlier. 56 | 57 | ## Table of Contents ## 58 | 59 | * [Examples](#examples) 60 | * [Getting Started](#getting-started) including installation with Composer and setup for GDS Emulator 61 | * [Defining Your Model](#defining-your-model) 62 | * [Creating Records](#creating-records) 63 | * [Timezones and DateTime](#updated-timezone-support) 64 | * [Geopoint Support](#geopoint) 65 | * [Queries, GQL & The Default Query](#queries-gql--the-default-query) 66 | * [Multi-tenant Applications & Data Namespaces](#multi-tenant-applications--data-namespaces) 67 | * [Entity Groups, Hierarchy & Ancestors](#entity-groups-hierarchy--ancestors) 68 | * [Transactions](#transactions) 69 | * [Data Migrations](#data-migrations) 70 | * [More About Google Cloud Datastore](#more-about-google-cloud-datastore) 71 | * [Unit Tests](#unit-tests) 72 | * [Footnotes](#footnotes) 73 | 74 | ## Using the REST API (default from 5.0) ## 75 | 76 | As of PHP-GDS version 5, the REST API Gateway is the default. 77 | 78 | It will attempt to auto-detect your Google Project ID - and usually the Google auth library will use the default application credentials. 79 | 80 | You might need to set an environment variable with the path to your JSON credentials file first, usually if you're running outside of App Engine or Google Compute Engine. 81 | 82 | ```php 83 | putenv('GOOGLE_APPLICATION_CREDENTIALS=/path/to/my/credentials.json'); 84 | 85 | // A regular Store, but with a custom Gateway 86 | $obj_book_store = new GDS\Store('Book', new \GDS\Gateway\RESTv1('my-project-id')); 87 | ``` 88 | 89 | You can find out more about the auth system here: [Google Auth Library for PHP](https://github.com/google/google-auth-library-php) 90 | 91 | You can download a service account JSON file from the Google Cloud Console `API Manager > Credentials`. 92 | 93 | ## Firestore in Datastore Mode ## 94 | 95 | If you are using PHP-GDS version 4 or earlier, and Firestore in Datastore mode from App Engine standard (first generation), you may run into `Internal Error`s from the `Protobuf` gateway. 96 | 97 | You can resolve this by using the `RESTv1` Gateway & APIs. [See here for basic guidance](#using-the-datastore-rest-api-v1-sep-2016) 98 | 99 | More details and recommended upgrade paths to come. Along with better gRPC support for outside App Engine. 100 | 101 | ## Examples ## 102 | 103 | I find examples a great way to decide if I want to even try out a library, so here's a couple for you. 104 | 105 | ```php 106 | // Build a new entity 107 | $obj_book = new GDS\Entity(); 108 | $obj_book->title = 'Romeo and Juliet'; 109 | $obj_book->author = 'William Shakespeare'; 110 | $obj_book->isbn = '1840224339'; 111 | 112 | // Write it to Datastore 113 | $obj_store = new GDS\Store('Book'); 114 | $obj_store->upsert($obj_book); 115 | ``` 116 | 117 | You can also use the [Alternative Array Syntax](#alternative-array-syntax) for creating Entity objects, like this 118 | 119 | ```php 120 | $obj_book = $obj_store->createEntity([ 121 | 'title' => 'The Merchant of Venice', 122 | 'author' => 'William Shakespeare', 123 | 'isbn' => '1840224312' 124 | ]); 125 | ``` 126 | 127 | Now let's fetch all the Books from the Datastore and display their titles and ISBN numbers 128 | 129 | ```php 130 | $obj_store = new GDS\Store('Book'); 131 | foreach($obj_store->fetchAll() as $obj_book) { 132 | echo "Title: {$obj_book->title}, ISBN: {$obj_book->isbn}
", PHP_EOL; 133 | } 134 | ``` 135 | 136 | ### More about the Examples ### 137 | 138 | These initial examples assume you are either running a Google AppEngine application or in a local AppEngine dev environment. 139 | In both of these cases, we can auto detect the **dataset**. 140 | 141 | We use a `GDS\Store` to read and write `GDS\Entity` objects to and from Datastore. 142 | 143 | These examples use the generic `GDS\Entity` class with a dynamic Schema. See [Defining Your Model](#defining-your-model) below for more details on custom Schemas and indexed fields. 144 | 145 | ### Demo Application ### 146 | 147 | A simple guest book application 148 | 149 | Application: http://php-gds-demo.appspot.com/ 150 | 151 | Code: https://github.com/tomwalder/php-gds-demo 152 | 153 | ## Changes in Version 5 ## 154 | 155 | * Add PHP 7 support 156 | * Remove PHP 5 support 157 | * Remove App Engine first-generation runtime support (inc direct Protocol Buffer API) 158 | 159 | ### Updated Timezone Support ### 160 | 161 | In 5.1, timezone support has been improved for `DateTime` objects going in & out of Datastore. 162 | 163 | #### How the data is stored 164 | Datastore keeps the data recorded as UTC. When you browse data in the Google Cloud Console, they represent it in your locale. 165 | 166 | #### Data coming out through PHP-GDS as Entities 167 | You can now expect any `DateTime` object coming out of Datastore from PHP-GDS to have your current PHP default timezone applied. Example follows: 168 | 169 | ```php 170 | date_default_timezone_set('America/New_York'); 171 | 172 | $obj_store = new GDS\Store('Book'); 173 | $obj_book = $obj_store->fetchOne(); 174 | echo $obj_book->published->format('c'); // 2004-02-12T15:19:21-05:00 175 | echo $obj_book->published->getTimezone()->getName(); // America/New_York 176 | ``` 177 | 178 | #### Data going in - multi format support 179 | If you pass in a `DateTime` object (or anything matching `DateTimeInterface`), we will respect the timezone set on it. 180 | 181 | 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: 182 | https://www.php.net/manual/en/datetime.construct.php 183 | 184 | 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. 185 | 186 | ## Changes in Version 4 ## 187 | 188 | * More consistent use of `DateTime` objects - now all result sets will use them instead of `Y-m-d H:i:s` strings 189 | * Move the `google/auth` to an optional dependency - if you need the REST API 190 | 191 | ## Changes in Version 3 ## 192 | 193 | * Support for the new **Datastore API, v1 - via REST** 194 | * Removal of support for the old 1.x series "PHP Google API Client" 195 | * **GeoPoint data is now supported over the REST API** v1 as well as ProtoBuf 196 | 197 | ## Getting Started ## 198 | 199 | Are you sitting comfortably? Before we begin, you will need: 200 | - a Google Account (doh), usually for running AppEngine - but not always 201 | - a Project to work on with the "Google Cloud Datastore API" turned ON [Google Developer Console](https://console.developers.google.com/) 202 | 203 | If you want to use the JSON API from remote or non-App Engine environments, you will also need 204 | - Application default credentials **OR** 205 | - a "Service account" and the JSON service key file, downloadable from the Developer Console 206 | 207 | ### Composer, Dependencies ### 208 | 209 | To install using Composer 210 | 211 | ```bash 212 | composer require "tomwalder/php-gds:^5.1" 213 | ``` 214 | 215 | ### Use with the Datastore Emulator ### 216 | Local development is supported using the REST Gateway and the Datastore Emulator. 217 | 218 | Detailed instructions can be found here: 219 | https://cloud.google.com/datastore/docs/tools/datastore-emulator 220 | 221 | ## Defining Your Model ## 222 | 223 | Because Datastore is schemaless, the library also supports fields/properties that are not explicitly defined. But it often makes a lot of sense to define your Entity Schema up front. 224 | 225 | Here is how we might build the Schema for our examples, with a Datastore Entity Kind of "Book" and 3 fields. 226 | 227 | ```php 228 | $obj_schema = (new GDS\Schema('Book')) 229 | ->addString('title') 230 | ->addString('author') 231 | ->addString('isbn'); 232 | 233 | // The Store accepts a Schema object or Kind name as its first parameter 234 | $obj_book_store = new GDS\Store($obj_schema); 235 | ``` 236 | 237 | By default, all fields are indexed. An indexed field can be used in a WHERE clause. You can explicitly configure a field to be not indexed by passing in `FALSE` as the second parameter to `addString()`. 238 | 239 | If you use a dynamic schema (i.e. do not define on, but just use the Entity name) then all fields will be indexed for that record. 240 | 241 | Available Schema configuration methods: 242 | - `GDS\Schema::addString` 243 | - `GDS\Schema::addInteger` 244 | - `GDS\Schema::addDatetime` 245 | - `GDS\Schema::addFloat` 246 | - `GDS\Schema::addBoolean` 247 | - `GDS\Schema::addStringList` 248 | - `GDS\Schema::addGeopoint` 249 | 250 | Take a look at the `examples` folder for a fully operational set of code. 251 | 252 | ## Creating Records ## 253 | 254 | ### Alternative Array Syntax ### 255 | 256 | There is an alternative to directly constructing a new `GDS\Entity` and setting its member data, which is to use the `GDS\Store::createEntity` factory method as follows. 257 | 258 | ```php 259 | $obj_book = $obj_book_store->createEntity([ 260 | 'title' => 'The Merchant of Venice', 261 | 'author' => 'William Shakespeare', 262 | 'isbn' => '1840224312' 263 | ]); 264 | ``` 265 | 266 | ## Special Properties ## 267 | 268 | Other than scalar values, there are two "object" data types supported: 269 | 270 | ### DateTime ### 271 | 272 | Support for DateTime object binding (also see query parameter binding below) 273 | 274 | ```php 275 | $obj_book = $obj_book_store->createEntity([ 276 | 'title' => 'Some Book', 277 | 'author' => 'A N Other Guy', 278 | 'isbn' => '1840224313', 279 | 'published' => new DateTime('-5 years') 280 | ]); 281 | ``` 282 | 283 | ### Geopoint ### 284 | 285 | The library has recently had support added for Geopoint properties. 286 | 287 | ```php 288 | $obj_schema->addGeopoint('location'); 289 | ``` 290 | 291 | Then when setting data, use the `Geopoint` object 292 | 293 | ```php 294 | $obj_person->location = new GDS\Property\Geopoint(53.4723272, -2.2936314); 295 | ``` 296 | 297 | And when pulling geopoint data out of a result: 298 | 299 | ```php 300 | echo $obj_person->location->getLatitude(); 301 | echo $obj_person->location->getLongitude(); 302 | ``` 303 | 304 | **It is not currently possible to query Geopoint fields, although this feature is in Alpha with Google** 305 | 306 | ## Queries, GQL & The Default Query ## 307 | 308 | The `GDS\Store` object uses Datastore GQL as its query language. Here is an example: 309 | 310 | ```php 311 | $obj_book_store->fetchOne("SELECT * FROM Book WHERE isbn = '1853260304'"); 312 | ``` 313 | 314 | And with support for named parameter binding (strings, integers) (*this is recommended*) 315 | 316 | ```php 317 | $obj_book_store->fetchOne("SELECT * FROM Book WHERE isbn = @isbnNumber", [ 318 | 'isbnNumber' => '1853260304' 319 | ]); 320 | ``` 321 | 322 | Support for DateTime object binding 323 | 324 | ```php 325 | $obj_book_store->fetchOne("SELECT * FROM Task WHERE date_date < @now", [ 326 | 'now' => new DateTime() 327 | ]); 328 | ``` 329 | 330 | We provide a couple of helper methods for some common (root Entity) queries, single and batch (much more efficient than many individual fetch calls): 331 | 332 | - `GDS\Store::fetchById` 333 | - `GDS\Store::fetchByIds` - batch fetching 334 | - `GDS\Store::fetchByName` 335 | - `GDS\Store::fetchByNames` - batch fetching 336 | 337 | When you instantiate a store object, like `BookStore` in our example, it comes pre-loaded with a default GQL query of the following form (this is "The Default Query") 338 | 339 | ```sql 340 | SELECT * FROM ORDER BY __key__ ASC 341 | ``` 342 | 343 | Which means you can quickly and easily get one or many records without needing to write any GQL, like this: 344 | 345 | ```php 346 | $obj_store->fetchOne(); // Gets the first book 347 | $obj_store->fetchAll(); // Gets all books 348 | $obj_store->fetchPage(10); // Gets the first 10 books 349 | ``` 350 | 351 | ### 1000 Result Batch Limit ### 352 | 353 | By default, this library will include a 1,000 record "batch size". 354 | 355 | This means calling `fetchAll()` will only return 1,000 records. 356 | 357 | I suggest paging your results if you need more than 1,000 records using `fetchPage()`. 358 | 359 | ### GQL on the Local Development Server ### 360 | 361 | At the time of writing, the Google App Engine local development server does not support GQL. So, **as of 2.0 I have included a basic GQL parser, which is only used in local development environments** and should mean you can run most application scenarios locally as you can on live. 362 | 363 | The GQL parser should be considered a "for fun" tool, rather than a production-ready service. 364 | 365 | Feedback very much appreciated - if you have GQL queries that fail to run, just raise an issue and I'll see what I can do (or fork & PR!). 366 | 367 | ### Pagination ### 368 | 369 | When working with larger data sets, it can be useful to page through results in smaller batches. Here's an example paging through all Books in 50's. 370 | 371 | ```php 372 | $obj_book_store->query('SELECT * FROM Book'); 373 | while($arr_page = $obj_book_store->fetchPage(50)) { 374 | echo "Page contains ", count($arr_page), " records", PHP_EOL; 375 | } 376 | ``` 377 | 378 | #### Limits, Offsets & Cursors #### 379 | 380 | In a standard SQL environment, the above pagination would look something like this: 381 | 382 | - `SELECT * FROM Book LIMIT 0, 50` for the first page 383 | - `SELECT * FROM Book LIMIT 50, 50` for the second, and so on. 384 | 385 | Although you can use a very similar syntax with Datastore GQL, it can be unnecessarily costly. This is because each row scanned when running a query is charged for. So, doing the equivalent of `LIMIT 5000, 50` will count as 5,050 reads - not just the 50 we actually get back. 386 | 387 | This is all fixed by using Cursors. The implementation is all encapsulated within the `GDS\Gateway` class so you don't need to worry about it. 388 | 389 | Bototm line: the bult-in pagination uses Cursors whenever possible for fastest & cheapest results. 390 | 391 | #### Tips for LIMIT-ed fetch operations #### 392 | 393 | Do not supply a `LIMIT` clause when calling 394 | - `GDS\Store::fetchOne` - it's done for you (we add `LIMIT 1`) 395 | - `GDS\Store::fetchPage` - again, it's done for you and it will cause a conflict. 396 | 397 | #### Pricing & Cursor References #### 398 | 399 | - [Query Cursors](https://cloud.google.com/datastore/docs/concepts/queries#Datastore_Query_cursors) 400 | - [Costs for Datastore Calls](https://cloud.google.com/appengine/pricing) 401 | - [Datastore Quotas](https://cloud.google.com/appengine/docs/quotas#Datastore) 402 | 403 | ## Multi-tenant Applications & Data Namespaces ## 404 | 405 | Google Datastore supports segregating data within a single "Dataset" using something called Namespaces. 406 | 407 | Generally, this is intended for multi-tenant applications where each customer would have separate data, even within the same "Kind". 408 | 409 | This library supports namespaces, and they are be configured per `Gateway` instance by passing in the optional 3rd namespace parameter. 410 | 411 | ALL operations carried out through a Gateway with a namespace configured are done in the context of that namespace. The namespace is automatically applied to Keys when doing upsert/delete/fetch-by-key and to Requests when running GQL queries. 412 | 413 | ```php 414 | // Create a store for a particular customer or 'application namespace' 415 | $obj_gateway = new \GDS\Gateway\RESTv1('project-id', 'namespace'); 416 | $obj_store = new \GDS\Store('Book', $obj_gateway); 417 | ``` 418 | 419 | Further examples are included in the examples folder. 420 | 421 | ## Entity Groups, Hierarchy & Ancestors ## 422 | 423 | Google Datastore allows for (and encourages) Entities to be organised in a hierarchy. 424 | 425 | The hierarchy allows for some amount of "relational" data. e.g. a `ForumThread` entity might have one more more `ForumPosts` entities as children. 426 | 427 | Entity groups are quite an advanced topic, but can positively affect your application in a number of areas including 428 | 429 | - Transactional integrity 430 | - Strongly consistent data 431 | 432 | At the time of writing, I support working with entity groups through the following methods 433 | 434 | - `GDS\Entity::setAncestry` 435 | - `GDS\Entity::getAncestry` 436 | - `GDS\Store::fetchEntityGroup` 437 | 438 | ## Transactions ## 439 | 440 | The `GDS\Store` supports running updates and deletes in transactions. 441 | 442 | To start a transaction 443 | 444 | ```php 445 | $obj_store->beginTransaction(); 446 | ``` 447 | 448 | Then, any operation that changes data will commit *and consume* the transaction. So an immediate call to another operation WILL NOT BE TRANSACTIONAL. 449 | 450 | ```php 451 | // Data changed within a transaction 452 | $obj_store->upsert($obj_entity); 453 | 454 | // Not transactional 455 | $obj_store->delete($obj_entity); 456 | ``` 457 | 458 | Watch out for `GDS\Exception\Contention` exceptions - they should be thrown by the library if you manage to hit Datastore contention locally in development or through the live Gateways. 459 | 460 | ## Custom Entity Classes and Stores ## 461 | 462 | Whilst you can use the `GDS\Entity` and `GDS\Store` classes directly, as per the examples above, you may find it useful to extend one or the other. 463 | 464 | For example 465 | 466 | ```php 467 | class Book extends GDS\Entity { /* ... */ } 468 | $obj_store->setEntityClass('\\Book'); 469 | ``` 470 | 471 | This way, when you pull objects out of Datastore, they are objects of your defined Entity class. 472 | 473 | The `Schema` holds the custom entity class name - this can be set directly, or via the `Store` object. 474 | 475 | ## Re-indexing ## 476 | 477 | When you change a field from non-indexed to indexed you will need to "re-index" all your existing entities before they will be returned in queries run against that index by Datastore. This is due to the way Google update their BigTable indexes. 478 | 479 | I've included a simple example (paginated) re-index script in the examples folder, `reindex.php`. 480 | 481 | ## Data Migrations ## 482 | 483 | Using multiple Gateway classes, you can move data between namespaces 484 | 485 | ```php 486 | // Name-spaced Gateways 487 | $obj_gateway_one = new \GDS\Gateway\RESTv1('project-id', 'namespace_one'); 488 | $obj_gateway_two = new \GDS\Gateway\RESTv1('project-id', 'namespace_two'); 489 | 490 | // Grab some books from one 491 | $arr_books = (new \GDS\Store('Book', $obj_gateway_one))->fetchPage(20); 492 | 493 | // And insert to two 494 | (new \GDS\Store('Book', $obj_gateway_two))->upsert($arr_books); 495 | ``` 496 | 497 | and between local and live environments. 498 | 499 | ```php 500 | // Local and Remote Gateways 501 | $obj_gateway_local = new \GDS\Gateway\ProtoBuf(); 502 | $obj_gateway_remote = new \GDS\Gateway\RESTv1('project-name'); 503 | 504 | // Grab some books from local 505 | $arr_books = (new \GDS\Store('Book', $obj_gateway_local))->fetchPage(20); 506 | 507 | // And insert to remote 508 | (new \GDS\Store('Book', $obj_gateway_remote))->upsert($arr_books); 509 | ``` 510 | 511 | *Note: In both of these examples, the entities will be inserted with the same KeyID or KeyName* 512 | 513 | ## More About Google Cloud Datastore ## 514 | 515 | What Google says: 516 | 517 | > "Use a managed, NoSQL, schemaless database for storing non-relational data. Cloud Datastore automatically scales as you need it and supports transactions as well as robust, SQL-like queries." 518 | 519 | https://cloud.google.com/datastore/ 520 | 521 | ### Specific Topics ### 522 | 523 | A few highlighted topics you might want to read up on 524 | - [Entities, Data Types etc.](https://cloud.google.com/datastore/docs/concepts/entities) 525 | - [More information on GQL](https://cloud.google.com/datastore/docs/concepts/gql) 526 | - [GQL Reference](https://cloud.google.com/datastore/docs/apis/gql/gql_reference) 527 | - [Indexes](https://cloud.google.com/datastore/docs/concepts/indexes) 528 | - [Ancestors](https://cloud.google.com/datastore/docs/concepts/entities#Datastore_Ancestor_paths) 529 | - [More about Datastore Transactions](https://cloud.google.com/datastore/docs/concepts/transactions) 530 | 531 | ## Unit Tests ## 532 | 533 | A full suite of unit tests is in the works. Assuming you've installed `php-gds` and its dependencies with Composer, you can run 534 | 535 | ```bash 536 | vendor/bin/phpunit 537 | ``` 538 | Or, if you need to run containerised tests, you can use the `runphp` image (or any you choose) 539 | ```bash 540 | docker run --rm -it -v`pwd`:/app -w /app fluentthinking/runphp:7.4.33-v0.9.0 php /app/vendor/bin/phpunit 541 | ``` 542 | 543 | [Click here for more details](tests/). 544 | 545 | ## Footnotes ## 546 | 547 | I am certainly more familiar with SQL and relational data models so I think that may end up coming across in the code - rightly so or not! 548 | 549 | Thanks to @sjlangley for any and all input - especially around unit tests for Protocol Buffers. 550 | 551 | Whilst I am using this library in production, it is my hope that other people find it of use. Feedback appreciated. 552 | 553 | # Other App Engine Software # 554 | 555 | If you've enjoyed this, you might be interested in my [Full Text Search Library for PHP on Google App Engine](https://github.com/tomwalder/php-appengine-search) 556 | --------------------------------------------------------------------------------