From fb703d6ad78f64854262855433e466cb0cc4c56a Mon Sep 17 00:00:00 2001 From: John Pedrie Date: Wed, 31 Aug 2016 18:08:12 -0400 Subject: [PATCH] Implementation of Google Cloud Datastore (#101) * Implementation of Google Cloud Datastore * Refactor API * Implement __get and __set on Datastore\Entity * Datastore entity properties can now be referenced by their names as if they were properties on the instantiated object. Example: ```php $key = $datastore->key('Person', 'Bob'); $entity = $datastore->entity($key, [ 'firstName' => 'Bob', 'lastName' => 'Testguy' ]); $firstName = $entity['firstName']; // is equivalent to $firstName = $entity->firstName; ``` * IDs are allocated in `insertBatch()` rather than `keys()` * Updating entities is now safer. When running `updateBatch()` or `upsertBatch(), manually created entities will not save unless the `allowOverwrite` flag is set. This prevents accidental overwrite of existing data. * DateTime fields now transform back and forth as expected/ * `excludeFromIndexes` is now supported for entity keys. * Transactions have been refactored and improved. * Operation still exists, but it works as an underlying internal API for code reuse, rather than for direct interaction. It supplies functionality to DatastoreClient and Transaction. * Fix namespace propogation and map keys in entity save/fetch * Return useful response from mutate methods * Support concurrency control with baseVersion * Update to API v1 * Code review resolutions * Reorder filter parameters, add short comparison operators * Refactor, support Geopoints * Code review fixes * Update entity mapping * Support mapping different kinds to types --- README.md | 32 + docs/toc.json | 20 + scripts/DocGenerator.php | 22 +- .../Connection/ConnectionInterface.php | 55 + src/Datastore/Connection/Rest.php | 95 ++ .../ServiceDefinition/datastore-v1.json | 1075 +++++++++++++++++ src/Datastore/DatastoreClient.php | 892 ++++++++++++++ src/Datastore/DatastoreTrait.php | 53 + src/Datastore/Entity.php | 318 +++++ src/Datastore/EntityMapper.php | 393 ++++++ src/Datastore/GeoPoint.php | 181 +++ src/Datastore/Key.php | 422 +++++++ src/Datastore/Operation.php | 678 +++++++++++ src/Datastore/Query/GqlQuery.php | 242 ++++ src/Datastore/Query/Query.php | 481 ++++++++ src/Datastore/Query/QueryInterface.php | 59 + src/Datastore/Transaction.php | 461 +++++++ src/PubSub/Subscription.php | 4 +- src/ServiceBuilder.php | 146 +-- tests/Datastore/Connection/RestTest.php | 84 ++ tests/Datastore/DatastoreClientTest.php | 466 +++++++ tests/Datastore/DatastoreTraitTest.php | 57 + tests/Datastore/EntityMapperTest.php | 347 ++++++ tests/Datastore/EntityTest.php | 108 ++ tests/Datastore/GeoPointTest.php | 88 ++ tests/Datastore/KeyTest.php | 281 +++++ tests/Datastore/OperationTest.php | 630 ++++++++++ tests/Datastore/Query/GqlQueryTest.php | 94 ++ tests/Datastore/Query/QueryTest.php | 231 ++++ tests/Datastore/TransactionTest.php | 234 ++++ .../datastore/entity-batch-lookup.json | 38 + tests/fixtures/datastore/entity-result.json | 22 + tests/fixtures/datastore/query-results.json | 112 ++ 33 files changed, 8333 insertions(+), 88 deletions(-) create mode 100644 src/Datastore/Connection/ConnectionInterface.php create mode 100644 src/Datastore/Connection/Rest.php create mode 100644 src/Datastore/Connection/ServiceDefinition/datastore-v1.json create mode 100644 src/Datastore/DatastoreClient.php create mode 100644 src/Datastore/DatastoreTrait.php create mode 100644 src/Datastore/Entity.php create mode 100644 src/Datastore/EntityMapper.php create mode 100644 src/Datastore/GeoPoint.php create mode 100644 src/Datastore/Key.php create mode 100644 src/Datastore/Operation.php create mode 100644 src/Datastore/Query/GqlQuery.php create mode 100644 src/Datastore/Query/Query.php create mode 100644 src/Datastore/Query/QueryInterface.php create mode 100644 src/Datastore/Transaction.php create mode 100644 tests/Datastore/Connection/RestTest.php create mode 100644 tests/Datastore/DatastoreClientTest.php create mode 100644 tests/Datastore/DatastoreTraitTest.php create mode 100644 tests/Datastore/EntityMapperTest.php create mode 100644 tests/Datastore/EntityTest.php create mode 100644 tests/Datastore/GeoPointTest.php create mode 100644 tests/Datastore/KeyTest.php create mode 100644 tests/Datastore/OperationTest.php create mode 100644 tests/Datastore/Query/GqlQueryTest.php create mode 100644 tests/Datastore/Query/QueryTest.php create mode 100644 tests/Datastore/TransactionTest.php create mode 100644 tests/fixtures/datastore/entity-batch-lookup.json create mode 100644 tests/fixtures/datastore/entity-result.json create mode 100644 tests/fixtures/datastore/query-results.json diff --git a/README.md b/README.md index fa318b0403b9..ad03c2b6e2b4 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This client supports the following Google Cloud Platform services: * [Google BigQuery](#google-bigquery) * [Google Stackdriver Logging](#google-stackdriver-logging) * [Google Translate](#google-translate) +* [Google Cloud Datastore](#google-cloud-datastore) * [Google Cloud Natural Language](#google-cloud-natural-language) * [Google Cloud Pub/Sub](#google-cloud-pubsub) * [Google Cloud Storage](#google-cloud-storage) @@ -145,6 +146,37 @@ foreach ($languages as $language) { } ``` +## Google Cloud Datastore + +- [API Documentation](http://googlecloudplatform.github.io/google-cloud-php/#/docs/latest/datastore/datastoreclient) +- [Official Documentation](https://cloud.google.com/datastore/docs/) + +#### Preview + +```php +require 'vendor/autoload.php'; + +use Google\Cloud\Datastore\DatastoreClient; + +$datastore = new DatastoreClient([ + 'projectId' => 'my_project' +]); + +// Create an entity +$bob = $datastore->entity('Person'); +$bob['firstName'] = 'Bob'; +$bob['email'] = 'bob@example.com'; +$datastore->insert($bob); + +// Fetch an entity +$key = $datastore->key('Person', 'Bob'); +$bob = $datastore->lookup($key); + +// Update an entity +$bob['email'] = 'bobv2@example.com'; +$datastore->update($bob); +``` + ## Google Cloud Natural Language - [API Documentation](http://googlecloudplatform.github.io/google-cloud-php/#/docs/latest/naturallanguage/naturallanguageclient) diff --git a/docs/toc.json b/docs/toc.json index 6235722b3176..cbf3faacd91d 100644 --- a/docs/toc.json +++ b/docs/toc.json @@ -55,6 +55,26 @@ "type": "bigquery/table" }] }, + { + "title": "Datastore", + "type": "datastore/datastoreclient", + "nav": [{ + "title": "Transaction", + "type": "datastore/transaction" + }, { + "title": "Entity", + "type": "datastore/entity" + }, { + "title": "Key", + "type": "datastore/key" + }, { + "title": "Query", + "type": "datastore/query/query" + }, { + "title": "GQL Query", + "type": "datastore/query/gqlquery" + }] + }, { "title": "Logging", "type": "logging/loggingclient", diff --git a/scripts/DocGenerator.php b/scripts/DocGenerator.php index 0cdfae441e66..918f8e97df0f 100644 --- a/scripts/DocGenerator.php +++ b/scripts/DocGenerator.php @@ -281,15 +281,18 @@ private function buildExamples($examples) unset($lines[0]); } + $captionLines = []; foreach ($lines as $key => $line) { if (substr($line, 0, 2) === '//') { - $caption .= $this->markdown->parse(substr($line, 3)); + $captionLines[] = substr($line, 3); unset($lines[$key]); } else { break; } } + $caption = $this->markdown->parse(implode(' ', $captionLines)); + $examplesArray[] = [ 'caption' => $caption, 'code' => implode(PHP_EOL, $lines) @@ -329,15 +332,19 @@ private function buildParams($params) $description = $param->getDescription(); $nestedParamsArray = []; - if ($param->getType() === 'array' && $this->hasNestedParams($description)) { + if (($param->getType() === 'array' || $param->getType() === 'array[]') && $this->hasNestedParams($description)) { $description = substr($description, 1, -1); $nestedParams = explode('@type', $description); $description = trim(array_shift($nestedParams)); $nestedParamsArray = $this->buildNestedParams($nestedParams, $param); } + $varName = substr($param->getVariableName(), 1); + if (!$varName) { + throw new \Exception('invalid or missing parameter name in "'. $param->getDocBlock()->getShortDescription() .'"'); + } $paramsArray[] = [ - 'name' => substr($param->getVariableName(), 1), + 'name' => $varName, 'description' => $this->buildDescription($param->getDocBlock(), $description), 'types' => $this->handleTypes($param->getTypes()), 'optional' => null, // @todo @@ -359,7 +366,12 @@ private function buildNestedParams($nestedParams, $origParam) $paramsArray = []; foreach ($nestedParams as $param) { - list($type, $name, $description) = explode(' ', trim($param), 3); + $nestedParam = explode(' ', trim($param), 3); + if (count($nestedParam) < 3) { + throw new \Exception('nested param is in an invalid format: '. $param); + } + + list($type, $name, $description) = $nestedParam; $name = substr($name, 1); $description = preg_replace('/\s+/', ' ', $description); $types = explode('|', $type); @@ -418,7 +430,7 @@ private function buildReturns($returns) foreach ($returns as $return) { $returnsArray[] = [ 'types' => $this->handleTypes($return->getTypes()), - 'description' => $this->markdown->parse($return->getDescription()) + 'description' => $this->buildDescription(null, $return->getDescription()) ]; } diff --git a/src/Datastore/Connection/ConnectionInterface.php b/src/Datastore/Connection/ConnectionInterface.php new file mode 100644 index 000000000000..bddd33f451c2 --- /dev/null +++ b/src/Datastore/Connection/ConnectionInterface.php @@ -0,0 +1,55 @@ +setRequestWrapper(new RequestWrapper($config)); + $this->setRequestBuilder(new RequestBuilder( + __DIR__ . '/ServiceDefinition/datastore-v1.json', + self::BASE_URI + )); + } + + /** + * @param array $args + */ + public function allocateIds(array $args) + { + return $this->send('projects', 'allocateIds', $args); + } + + /** + * @param array $args + */ + public function beginTransaction(array $args) + { + return $this->send('projects', 'beginTransaction', $args); + } + + /** + * @param array $args + */ + public function commit(array $args) + { + return $this->send('projects', 'commit', $args); + } + + /** + * @param array $args + */ + public function lookup(array $args) + { + return $this->send('projects', 'lookup', $args); + } + + /** + * @param array $args + */ + public function rollback(array $args) + { + return $this->send('projects', 'rollback', $args); + } + + /** + * @param array $args + */ + public function runQuery(array $args) + { + return $this->send('projects', 'runQuery', $args); + } +} diff --git a/src/Datastore/Connection/ServiceDefinition/datastore-v1.json b/src/Datastore/Connection/ServiceDefinition/datastore-v1.json new file mode 100644 index 000000000000..270726fbeb62 --- /dev/null +++ b/src/Datastore/Connection/ServiceDefinition/datastore-v1.json @@ -0,0 +1,1075 @@ +{ + "id": "datastore:v1", + "auth": { + "oauth2": { + "scopes": { + "https://www.googleapis.com/auth/cloud-platform": { + "description": "View and manage your data across Google Cloud Platform services" + }, + "https://www.googleapis.com/auth/datastore": { + "description": "View and manage your Google Cloud Datastore data" + } + } + } + }, + "description": "Accesses the schemaless NoSQL database to provide fully managed, robust, scalable storage for your application.\n", + "protocol": "rest", + "title": "Google Cloud Datastore API", + "resources": { + "projects": { + "methods": { + "runQuery": { + "id": "datastore.projects.runQuery", + "response": { + "$ref": "RunQueryResponse" + }, + "parameterOrder": [ + "projectId" + ], + "description": "Queries for entities.", + "request": { + "$ref": "RunQueryRequest" + }, + "flatPath": "v1/projects/{projectId}:runQuery", + "httpMethod": "POST", + "parameters": { + "projectId": { + "description": "The ID of the project against which to make the request.", + "required": true, + "location": "path", + "type": "string" + } + }, + "path": "v1/projects/{projectId}:runQuery", + "scopes": [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/datastore" + ] + }, + "beginTransaction": { + "id": "datastore.projects.beginTransaction", + "response": { + "$ref": "BeginTransactionResponse" + }, + "parameterOrder": [ + "projectId" + ], + "description": "Begins a new transaction.", + "request": { + "$ref": "BeginTransactionRequest" + }, + "flatPath": "v1/projects/{projectId}:beginTransaction", + "httpMethod": "POST", + "parameters": { + "projectId": { + "description": "The ID of the project against which to make the request.", + "required": true, + "location": "path", + "type": "string" + } + }, + "path": "v1/projects/{projectId}:beginTransaction", + "scopes": [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/datastore" + ] + }, + "allocateIds": { + "id": "datastore.projects.allocateIds", + "response": { + "$ref": "AllocateIdsResponse" + }, + "parameterOrder": [ + "projectId" + ], + "description": "Allocates IDs for the given keys, which is useful for referencing an entity\nbefore it is inserted.", + "request": { + "$ref": "AllocateIdsRequest" + }, + "flatPath": "v1/projects/{projectId}:allocateIds", + "httpMethod": "POST", + "parameters": { + "projectId": { + "description": "The ID of the project against which to make the request.", + "required": true, + "location": "path", + "type": "string" + } + }, + "path": "v1/projects/{projectId}:allocateIds", + "scopes": [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/datastore" + ] + }, + "lookup": { + "id": "datastore.projects.lookup", + "response": { + "$ref": "LookupResponse" + }, + "parameterOrder": [ + "projectId" + ], + "description": "Looks up entities by key.", + "request": { + "$ref": "LookupRequest" + }, + "flatPath": "v1/projects/{projectId}:lookup", + "httpMethod": "POST", + "parameters": { + "projectId": { + "description": "The ID of the project against which to make the request.", + "required": true, + "location": "path", + "type": "string" + } + }, + "path": "v1/projects/{projectId}:lookup", + "scopes": [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/datastore" + ] + }, + "commit": { + "id": "datastore.projects.commit", + "response": { + "$ref": "CommitResponse" + }, + "parameterOrder": [ + "projectId" + ], + "description": "Commits a transaction, optionally creating, deleting or modifying some\nentities.", + "request": { + "$ref": "CommitRequest" + }, + "flatPath": "v1/projects/{projectId}:commit", + "httpMethod": "POST", + "parameters": { + "projectId": { + "description": "The ID of the project against which to make the request.", + "required": true, + "location": "path", + "type": "string" + } + }, + "path": "v1/projects/{projectId}:commit", + "scopes": [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/datastore" + ] + }, + "rollback": { + "id": "datastore.projects.rollback", + "response": { + "$ref": "RollbackResponse" + }, + "parameterOrder": [ + "projectId" + ], + "description": "Rolls back a transaction.", + "request": { + "$ref": "RollbackRequest" + }, + "flatPath": "v1/projects/{projectId}:rollback", + "httpMethod": "POST", + "parameters": { + "projectId": { + "description": "The ID of the project against which to make the request.", + "required": true, + "location": "path", + "type": "string" + } + }, + "path": "v1/projects/{projectId}:rollback", + "scopes": [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/datastore" + ] + } + } + } + }, + "schemas": { + "Value": { + "description": "A message that can hold any of the supported value types and associated\nmetadata.", + "type": "object", + "properties": { + "stringValue": { + "description": "A UTF-8 encoded string value.\nWhen `exclude_from_indexes` is false (it is indexed) , may have at most 1500 bytes.\nOtherwise, may be set to at least 1,000,000 bytes.", + "type": "string" + }, + "arrayValue": { + "description": "An array value.\nCannot contain another array value.\nA `Value` instance that sets field `array_value` must not set fields\n`meaning` or `exclude_from_indexes`.", + "$ref": "ArrayValue" + }, + "entityValue": { + "description": "An entity value.\n\n- May have no key.\n- May have a key with an incomplete key path.\n- May have a reserved/read-only key.", + "$ref": "Entity" + }, + "meaning": { + "description": "The `meaning` field should only be populated for backwards compatibility.", + "type": "integer", + "format": "int32" + }, + "integerValue": { + "description": "An integer value.", + "type": "string", + "format": "int64" + }, + "doubleValue": { + "description": "A double value.", + "type": "number", + "format": "double" + }, + "blobValue": { + "description": "A blob value.\nMay have at most 1,000,000 bytes.\nWhen `exclude_from_indexes` is false, may have at most 1500 bytes.\nIn JSON requests, must be base64-encoded.", + "type": "string", + "format": "byte" + }, + "geoPointValue": { + "description": "A geo point value representing a point on the surface of Earth.", + "$ref": "LatLng" + }, + "nullValue": { + "description": "A null value.", + "enum": [ + "NULL_VALUE" + ], + "enumDescriptions": [ + "Null value." + ], + "type": "string" + }, + "booleanValue": { + "description": "A boolean value.", + "type": "boolean" + }, + "keyValue": { + "description": "A key value.", + "$ref": "Key" + }, + "excludeFromIndexes": { + "description": "If the value should be excluded from all indexes including those defined\nexplicitly.", + "type": "boolean" + }, + "timestampValue": { + "description": "A timestamp value.\nWhen stored in the Datastore, precise only to microseconds;\nany additional precision is rounded down.", + "type": "string", + "format": "google-datetime" + } + }, + "id": "Value" + }, + "ReadOptions": { + "description": "The options shared by read requests.", + "type": "object", + "properties": { + "transaction": { + "description": "The identifier of the transaction in which to read. A\ntransaction identifier is returned by a call to\nDatastore.BeginTransaction.", + "type": "string", + "format": "byte" + }, + "readConsistency": { + "description": "The non-transactional read consistency to use.\nCannot be set to `STRONG` for global queries.", + "enum": [ + "READ_CONSISTENCY_UNSPECIFIED", + "STRONG", + "EVENTUAL" + ], + "enumDescriptions": [ + "Unspecified. This value must not be used.", + "Strong consistency.", + "Eventual consistency." + ], + "type": "string" + } + }, + "id": "ReadOptions" + }, + "PropertyOrder": { + "description": "The desired order for a specific property.", + "type": "object", + "properties": { + "direction": { + "description": "The direction to order by. Defaults to `ASCENDING`.", + "enum": [ + "DIRECTION_UNSPECIFIED", + "ASCENDING", + "DESCENDING" + ], + "enumDescriptions": [ + "Unspecified. This value must not be used.", + "Ascending.", + "Descending." + ], + "type": "string" + }, + "property": { + "description": "The property to order by.", + "$ref": "PropertyReference" + } + }, + "id": "PropertyOrder" + }, + "CommitRequest": { + "description": "The request for Datastore.Commit.", + "type": "object", + "properties": { + "transaction": { + "description": "The identifier of the transaction associated with the commit. A\ntransaction identifier is returned by a call to\nDatastore.BeginTransaction.", + "type": "string", + "format": "byte" + }, + "mode": { + "description": "The type of commit to perform. Defaults to `TRANSACTIONAL`.", + "enum": [ + "MODE_UNSPECIFIED", + "TRANSACTIONAL", + "NON_TRANSACTIONAL" + ], + "enumDescriptions": [ + "Unspecified. This value must not be used.", + "Transactional: The mutations are either all applied, or none are applied.\nLearn about transactions [here](https://cloud.google.com/datastore/docs/concepts/transactions).", + "Non-transactional: The mutations may not apply as all or none." + ], + "type": "string" + }, + "mutations": { + "description": "The mutations to perform.\n\nWhen mode is `TRANSACTIONAL`, mutations affecting a single entity are\napplied in order. The following sequences of mutations affecting a single\nentity are not permitted in a single `Commit` request:\n\n- `insert` followed by `insert`\n- `update` followed by `insert`\n- `upsert` followed by `insert`\n- `delete` followed by `update`\n\nWhen mode is `NON_TRANSACTIONAL`, no two mutations may affect a single\nentity.", + "type": "array", + "items": { + "$ref": "Mutation" + } + } + }, + "id": "CommitRequest" + }, + "Query": { + "description": "A query for entities.", + "type": "object", + "properties": { + "limit": { + "description": "The maximum number of results to return. Applies after all other\nconstraints. Optional.\nUnspecified is interpreted as no limit.\nMust be \u003e= 0 if specified.", + "type": "integer", + "format": "int32" + }, + "filter": { + "description": "The filter to apply.", + "$ref": "Filter" + }, + "endCursor": { + "description": "An ending point for the query results. Query cursors are\nreturned in query result batches and\n[can only be used to limit the same query](https://cloud.google.com/datastore/docs/concepts/queries#cursors_limits_and_offsets).", + "type": "string", + "format": "byte" + }, + "distinctOn": { + "description": "The properties to make distinct. The query results will contain the first\nresult for each distinct combination of values for the given properties\n(if empty, all results are returned).", + "type": "array", + "items": { + "$ref": "PropertyReference" + } + }, + "offset": { + "description": "The number of results to skip. Applies before limit, but after all other\nconstraints. Optional. Must be \u003e= 0 if specified.", + "type": "integer", + "format": "int32" + }, + "projection": { + "description": "The projection to return. Defaults to returning all properties.", + "type": "array", + "items": { + "$ref": "Projection" + } + }, + "order": { + "description": "The order to apply to the query results (if empty, order is unspecified).", + "type": "array", + "items": { + "$ref": "PropertyOrder" + } + }, + "startCursor": { + "description": "A starting point for the query results. Query cursors are\nreturned in query result batches and\n[can only be used to continue the same query](https://cloud.google.com/datastore/docs/concepts/queries#cursors_limits_and_offsets).", + "type": "string", + "format": "byte" + }, + "kind": { + "description": "The kinds to query (if empty, returns entities of all kinds).\nCurrently at most 1 kind may be specified.", + "type": "array", + "items": { + "$ref": "KindExpression" + } + } + }, + "id": "Query" + }, + "RollbackRequest": { + "description": "The request for Datastore.Rollback.", + "type": "object", + "properties": { + "transaction": { + "description": "The transaction identifier, returned by a call to\nDatastore.BeginTransaction.", + "type": "string", + "format": "byte" + } + }, + "id": "RollbackRequest" + }, + "EntityResult": { + "description": "The result of fetching an entity from Datastore.", + "type": "object", + "properties": { + "cursor": { + "description": "A cursor that points to the position after the result entity.\nSet only when the `EntityResult` is part of a `QueryResultBatch` message.", + "type": "string", + "format": "byte" + }, + "entity": { + "description": "The resulting entity.", + "$ref": "Entity" + }, + "version": { + "description": "The version of the entity, a strictly positive number that monotonically\nincreases with changes to the entity.\n\nThis field is set for `FULL` entity\nresults.\n\nFor missing entities in `LookupResponse`, this\nis the version of the snapshot that was used to look up the entity, and it\nis always set except for eventually consistent reads.", + "type": "string", + "format": "int64" + } + }, + "id": "EntityResult" + }, + "GqlQueryParameter": { + "description": "A binding parameter for a GQL query.", + "type": "object", + "properties": { + "value": { + "description": "A value parameter.", + "$ref": "Value" + }, + "cursor": { + "description": "A query cursor. Query cursors are returned in query\nresult batches.", + "type": "string", + "format": "byte" + } + }, + "id": "GqlQueryParameter" + }, + "ArrayValue": { + "description": "An array value.", + "type": "object", + "properties": { + "values": { + "description": "Values in the array.\nThe order of this array may not be preserved if it contains a mix of\nindexed and unindexed values.", + "type": "array", + "items": { + "$ref": "Value" + } + } + }, + "id": "ArrayValue" + }, + "Filter": { + "description": "A holder for any type of filter.", + "type": "object", + "properties": { + "propertyFilter": { + "description": "A filter on a property.", + "$ref": "PropertyFilter" + }, + "compositeFilter": { + "description": "A composite filter.", + "$ref": "CompositeFilter" + } + }, + "id": "Filter" + }, + "BeginTransactionResponse": { + "description": "The response for Datastore.BeginTransaction.", + "type": "object", + "properties": { + "transaction": { + "description": "The transaction identifier (always present).", + "type": "string", + "format": "byte" + } + }, + "id": "BeginTransactionResponse" + }, + "PartitionId": { + "description": "A partition ID identifies a grouping of entities. The grouping is always\nby project and namespace, however the namespace ID may be empty.\n\nA partition ID contains several dimensions:\nproject ID and namespace ID.\n\nPartition dimensions:\n\n- May be `\"\"`.\n- Must be valid UTF-8 bytes.\n- Must have values that match regex `[A-Za-z\\d\\.\\-_]{1,100}`\nIf the value of any dimension matches regex `__.*__`, the partition is\nreserved/read-only.\nA reserved/read-only partition ID is forbidden in certain documented\ncontexts.\n\nForeign partition IDs (in which the project ID does\nnot match the context project ID ) are discouraged.\nReads and writes of foreign partition IDs may fail if the project is not in an active state.", + "type": "object", + "properties": { + "namespaceId": { + "description": "If not empty, the ID of the namespace to which the entities belong.", + "type": "string" + }, + "projectId": { + "description": "The ID of the project to which the entities belong.", + "type": "string" + } + }, + "id": "PartitionId" + }, + "QueryResultBatch": { + "description": "A batch of results produced by a query.", + "type": "object", + "properties": { + "snapshotVersion": { + "description": "The version number of the snapshot this batch was returned from.\nThis applies to the range of results from the query's `start_cursor` (or\nthe beginning of the query if no cursor was given) to this batch's\n`end_cursor` (not the query's `end_cursor`).\n\nIn a single transaction, subsequent query result batches for the same query\ncan have a greater snapshot version number. Each batch's snapshot version\nis valid for all preceding batches.", + "type": "string", + "format": "int64" + }, + "endCursor": { + "description": "A cursor that points to the position after the last result in the batch.", + "type": "string", + "format": "byte" + }, + "skippedCursor": { + "description": "A cursor that points to the position after the last skipped result.\nWill be set when `skipped_results` != 0.", + "type": "string", + "format": "byte" + }, + "entityResultType": { + "description": "The result type for every entity in `entity_results`.", + "enum": [ + "RESULT_TYPE_UNSPECIFIED", + "FULL", + "PROJECTION", + "KEY_ONLY" + ], + "enumDescriptions": [ + "Unspecified. This value is never used.", + "The key and properties.", + "A projected subset of properties. The entity may have no key.", + "Only the key." + ], + "type": "string" + }, + "moreResults": { + "description": "The state of the query after the current batch.", + "enum": [ + "MORE_RESULTS_TYPE_UNSPECIFIED", + "NOT_FINISHED", + "MORE_RESULTS_AFTER_LIMIT", + "MORE_RESULTS_AFTER_CURSOR", + "NO_MORE_RESULTS" + ], + "enumDescriptions": [ + "Unspecified. This value is never used.", + "There may be additional batches to fetch from this query.", + "The query is finished, but there may be more results after the limit.", + "The query is finished, but there may be more results after the end\ncursor.", + "The query has been exhausted." + ], + "type": "string" + }, + "entityResults": { + "description": "The results for this batch.", + "type": "array", + "items": { + "$ref": "EntityResult" + } + }, + "skippedResults": { + "description": "The number of results skipped, typically because of an offset.", + "type": "integer", + "format": "int32" + } + }, + "id": "QueryResultBatch" + }, + "AllocateIdsRequest": { + "description": "The request for Datastore.AllocateIds.", + "type": "object", + "properties": { + "keys": { + "description": "A list of keys with incomplete key paths for which to allocate IDs.\nNo key may be reserved/read-only.", + "type": "array", + "items": { + "$ref": "Key" + } + } + }, + "id": "AllocateIdsRequest" + }, + "KindExpression": { + "description": "A representation of a kind.", + "type": "object", + "properties": { + "name": { + "description": "The name of the kind.", + "type": "string" + } + }, + "id": "KindExpression" + }, + "PropertyFilter": { + "description": "A filter on a specific property.", + "type": "object", + "properties": { + "value": { + "description": "The value to compare the property to.", + "$ref": "Value" + }, + "op": { + "description": "The operator to filter by.", + "enum": [ + "OPERATOR_UNSPECIFIED", + "LESS_THAN", + "LESS_THAN_OR_EQUAL", + "GREATER_THAN", + "GREATER_THAN_OR_EQUAL", + "EQUAL", + "HAS_ANCESTOR" + ], + "enumDescriptions": [ + "Unspecified. This value must not be used.", + "Less than.", + "Less than or equal.", + "Greater than.", + "Greater than or equal.", + "Equal.", + "Has ancestor." + ], + "type": "string" + }, + "property": { + "description": "The property to filter by.", + "$ref": "PropertyReference" + } + }, + "id": "PropertyFilter" + }, + "PathElement": { + "description": "A (kind, ID/name) pair used to construct a key path.\n\nIf either name or ID is set, the element is complete.\nIf neither is set, the element is incomplete.", + "type": "object", + "properties": { + "kind": { + "description": "The kind of the entity.\nA kind matching regex `__.*__` is reserved/read-only.\nA kind must not contain more than 1500 bytes when UTF-8 encoded.\nCannot be `\"\"`.", + "type": "string" + }, + "id": { + "description": "The auto-allocated ID of the entity.\nNever equal to zero. Values less than zero are discouraged and may not\nbe supported in the future.", + "type": "string", + "format": "int64" + }, + "name": { + "description": "The name of the entity.\nA name matching regex `__.*__` is reserved/read-only.\nA name must not be more than 1500 bytes when UTF-8 encoded.\nCannot be `\"\"`.", + "type": "string" + } + }, + "id": "PathElement" + }, + "RollbackResponse": { + "description": "The response for Datastore.Rollback.\n(an empty message).", + "type": "object", + "properties": {}, + "id": "RollbackResponse" + }, + "PropertyReference": { + "description": "A reference to a property relative to the kind expressions.", + "type": "object", + "properties": { + "name": { + "description": "The name of the property.\nIf name includes \".\"s, it may be interpreted as a property name path.", + "type": "string" + } + }, + "id": "PropertyReference" + }, + "Projection": { + "description": "A representation of a property in a projection.", + "type": "object", + "properties": { + "property": { + "description": "The property to project.", + "$ref": "PropertyReference" + } + }, + "id": "Projection" + }, + "MutationResult": { + "description": "The result of applying a mutation.", + "type": "object", + "properties": { + "conflictDetected": { + "description": "Whether a conflict was detected for this mutation. Always false when a\nconflict detection strategy field is not set in the mutation.", + "type": "boolean" + }, + "key": { + "description": "The automatically allocated key.\nSet only when the mutation allocated a key.", + "$ref": "Key" + }, + "version": { + "description": "The version of the entity on the server after processing the mutation. If\nthe mutation doesn't change anything on the server, then the version will\nbe the version of the current entity or, if no entity is present, a version\nthat is strictly greater than the version of any previous entity and less\nthan the version of any possible future entity.", + "type": "string", + "format": "int64" + } + }, + "id": "MutationResult" + }, + "AllocateIdsResponse": { + "description": "The response for Datastore.AllocateIds.", + "type": "object", + "properties": { + "keys": { + "description": "The keys specified in the request (in the same order), each with\nits key path completed with a newly allocated ID.", + "type": "array", + "items": { + "$ref": "Key" + } + } + }, + "id": "AllocateIdsResponse" + }, + "LookupResponse": { + "description": "The response for Datastore.Lookup.", + "type": "object", + "properties": { + "found": { + "description": "Entities found as `ResultType.FULL` entities. The order of results in this\nfield is undefined and has no relation to the order of the keys in the\ninput.", + "type": "array", + "items": { + "$ref": "EntityResult" + } + }, + "missing": { + "description": "Entities not found as `ResultType.KEY_ONLY` entities. The order of results\nin this field is undefined and has no relation to the order of the keys\nin the input.", + "type": "array", + "items": { + "$ref": "EntityResult" + } + }, + "deferred": { + "description": "A list of keys that were not looked up due to resource constraints. The\norder of results in this field is undefined and has no relation to the\norder of the keys in the input.", + "type": "array", + "items": { + "$ref": "Key" + } + } + }, + "id": "LookupResponse" + }, + "BeginTransactionRequest": { + "description": "The request for Datastore.BeginTransaction.", + "type": "object", + "properties": {}, + "id": "BeginTransactionRequest" + }, + "Key": { + "description": "A unique identifier for an entity.\nIf a key's partition ID or any of its path kinds or names are\nreserved/read-only, the key is reserved/read-only.\nA reserved/read-only key is forbidden in certain documented contexts.", + "type": "object", + "properties": { + "partitionId": { + "description": "Entities are partitioned into subsets, currently identified by a project\nID and namespace ID.\nQueries are scoped to a single partition.", + "$ref": "PartitionId" + }, + "path": { + "description": "The entity path.\nAn entity path consists of one or more elements composed of a kind and a\nstring or numerical identifier, which identify entities. The first\nelement identifies a _root entity_, the second element identifies\na _child_ of the root entity, the third element identifies a child of the\nsecond entity, and so forth. The entities identified by all prefixes of\nthe path are called the element's _ancestors_.\n\nAn entity path is always fully complete: *all* of the entity's ancestors\nare required to be in the path along with the entity identifier itself.\nThe only exception is that in some documented cases, the identifier in the\nlast path element (for the entity) itself may be omitted. For example,\nthe last path element of the key of `Mutation.insert` may have no\nidentifier.\n\nA path can never be empty, and a path can have at most 100 elements.", + "type": "array", + "items": { + "$ref": "PathElement" + } + } + }, + "id": "Key" + }, + "RunQueryResponse": { + "description": "The response for Datastore.RunQuery.", + "type": "object", + "properties": { + "batch": { + "description": "A batch of query results (always present).", + "$ref": "QueryResultBatch" + }, + "query": { + "description": "The parsed form of the `GqlQuery` from the request, if it was set.", + "$ref": "Query" + } + }, + "id": "RunQueryResponse" + }, + "Entity": { + "description": "A Datastore data object.\n\nAn entity is limited to 1 megabyte when stored. That _roughly_\ncorresponds to a limit of 1 megabyte for the serialized form of this\nmessage.", + "type": "object", + "properties": { + "properties": { + "description": "The entity's properties.\nThe map's keys are property names.\nA property name matching regex `__.*__` is reserved.\nA reserved property name is forbidden in certain documented contexts.\nThe name must not contain more than 500 characters.\nThe name cannot be `\"\"`.", + "additionalProperties": { + "$ref": "Value" + }, + "type": "object" + }, + "key": { + "description": "The entity's key.\n\nAn entity must have a key, unless otherwise documented (for example,\nan entity in `Value.entity_value` may have no key).\nAn entity's kind is its key path's last element's kind,\nor null if it has no key.", + "$ref": "Key" + } + }, + "id": "Entity" + }, + "GqlQuery": { + "description": "A [GQL query](https://cloud.google.com/datastore/docs/apis/gql/gql_reference).", + "type": "object", + "properties": { + "queryString": { + "description": "A string of the format described\n[here](https://cloud.google.com/datastore/docs/apis/gql/gql_reference).", + "type": "string" + }, + "namedBindings": { + "description": "For each non-reserved named binding site in the query string, there must be\na named parameter with that name, but not necessarily the inverse.\n\nKey must match regex `A-Za-z_$*`, must not match regex\n`__.*__`, and must not be `\"\"`.", + "additionalProperties": { + "$ref": "GqlQueryParameter" + }, + "type": "object" + }, + "allowLiterals": { + "description": "When false, the query string must not contain any literals and instead must\nbind all values. For example,\n`SELECT * FROM Kind WHERE a = 'string literal'` is not allowed, while\n`SELECT * FROM Kind WHERE a = @value` is.", + "type": "boolean" + }, + "positionalBindings": { + "description": "Numbered binding site @1 references the first numbered parameter,\neffectively using 1-based indexing, rather than the usual 0.\n\nFor each binding site numbered i in `query_string`, there must be an i-th\nnumbered parameter. The inverse must also be true.", + "type": "array", + "items": { + "$ref": "GqlQueryParameter" + } + } + }, + "id": "GqlQuery" + }, + "Mutation": { + "description": "A mutation to apply to an entity.", + "type": "object", + "properties": { + "insert": { + "description": "The entity to insert. The entity must not already exist.\nThe entity key's final path element may be incomplete.", + "$ref": "Entity" + }, + "update": { + "description": "The entity to update. The entity must already exist.\nMust have a complete key path.", + "$ref": "Entity" + }, + "baseVersion": { + "description": "The version of the entity that this mutation is being applied to. If this\ndoes not match the current version on the server, the mutation conflicts.", + "type": "string", + "format": "int64" + }, + "upsert": { + "description": "The entity to upsert. The entity may or may not already exist.\nThe entity key's final path element may be incomplete.", + "$ref": "Entity" + }, + "delete": { + "description": "The key of the entity to delete. The entity may or may not already exist.\nMust have a complete key path and must not be reserved/read-only.", + "$ref": "Key" + } + }, + "id": "Mutation" + }, + "CommitResponse": { + "description": "The response for Datastore.Commit.", + "type": "object", + "properties": { + "mutationResults": { + "description": "The result of performing the mutations.\nThe i-th mutation result corresponds to the i-th mutation in the request.", + "type": "array", + "items": { + "$ref": "MutationResult" + } + }, + "indexUpdates": { + "description": "The number of index entries updated during the commit, or zero if none were\nupdated.", + "type": "integer", + "format": "int32" + } + }, + "id": "CommitResponse" + }, + "RunQueryRequest": { + "description": "The request for Datastore.RunQuery.", + "type": "object", + "properties": { + "partitionId": { + "description": "Entities are partitioned into subsets, identified by a partition ID.\nQueries are scoped to a single partition.\nThis partition ID is normalized with the standard default context\npartition ID.", + "$ref": "PartitionId" + }, + "gqlQuery": { + "description": "The GQL query to run.", + "$ref": "GqlQuery" + }, + "readOptions": { + "description": "The options for this query.", + "$ref": "ReadOptions" + }, + "query": { + "description": "The query to run.", + "$ref": "Query" + } + }, + "id": "RunQueryRequest" + }, + "LookupRequest": { + "description": "The request for Datastore.Lookup.", + "type": "object", + "properties": { + "readOptions": { + "description": "The options for this lookup request.", + "$ref": "ReadOptions" + }, + "keys": { + "description": "Keys of entities to look up.", + "type": "array", + "items": { + "$ref": "Key" + } + } + }, + "id": "LookupRequest" + }, + "LatLng": { + "description": "An object representing a latitude/longitude pair. This is expressed as a pair\nof doubles representing degrees latitude and degrees longitude. Unless\nspecified otherwise, this must conform to the\n\u003ca href=\"http://www.unoosa.org/pdf/icg/2012/template/WGS_84.pdf\"\u003eWGS84\nstandard\u003c/a\u003e. Values must be within normalized ranges.\n\nExample of normalization code in Python:\n\n def NormalizeLongitude(longitude):\n \"\"\"Wraps decimal degrees longitude to [-180.0, 180.0].\"\"\"\n q, r = divmod(longitude, 360.0)\n if r \u003e 180.0 or (r == 180.0 and q \u003c= -1.0):\n return r - 360.0\n return r\n\n def NormalizeLatLng(latitude, longitude):\n \"\"\"Wraps decimal degrees latitude and longitude to\n [-90.0, 90.0] and [-180.0, 180.0], respectively.\"\"\"\n r = latitude % 360.0\n if r \u003c= 90.0:\n return r, NormalizeLongitude(longitude)\n elif r \u003e= 270.0:\n return r - 360, NormalizeLongitude(longitude)\n else:\n return 180 - r, NormalizeLongitude(longitude + 180.0)\n\n assert 180.0 == NormalizeLongitude(180.0)\n assert -180.0 == NormalizeLongitude(-180.0)\n assert -179.0 == NormalizeLongitude(181.0)\n assert (0.0, 0.0) == NormalizeLatLng(360.0, 0.0)\n assert (0.0, 0.0) == NormalizeLatLng(-360.0, 0.0)\n assert (85.0, 180.0) == NormalizeLatLng(95.0, 0.0)\n assert (-85.0, -170.0) == NormalizeLatLng(-95.0, 10.0)\n assert (90.0, 10.0) == NormalizeLatLng(90.0, 10.0)\n assert (-90.0, -10.0) == NormalizeLatLng(-90.0, -10.0)\n assert (0.0, -170.0) == NormalizeLatLng(-180.0, 10.0)\n assert (0.0, -170.0) == NormalizeLatLng(180.0, 10.0)\n assert (-90.0, 10.0) == NormalizeLatLng(270.0, 10.0)\n assert (90.0, 10.0) == NormalizeLatLng(-270.0, 10.0)", + "type": "object", + "properties": { + "latitude": { + "description": "The latitude in degrees. It must be in the range [-90.0, +90.0].", + "type": "number", + "format": "double" + }, + "longitude": { + "description": "The longitude in degrees. It must be in the range [-180.0, +180.0].", + "type": "number", + "format": "double" + } + }, + "id": "LatLng" + }, + "CompositeFilter": { + "description": "A filter that merges multiple other filters using the given operator.", + "type": "object", + "properties": { + "op": { + "description": "The operator for combining multiple filters.", + "enum": [ + "OPERATOR_UNSPECIFIED", + "AND" + ], + "enumDescriptions": [ + "Unspecified. This value must not be used.", + "The results are required to satisfy each of the combined filters." + ], + "type": "string" + }, + "filters": { + "description": "The list of filters to combine.\nMust contain at least one filter.", + "type": "array", + "items": { + "$ref": "Filter" + } + } + }, + "id": "CompositeFilter" + } + }, + "revision": "20160816", + "basePath": "", + "icons": { + "x32": "http://www.google.com/images/icons/product/search-32.gif", + "x16": "http://www.google.com/images/icons/product/search-16.gif" + }, + "version_module": "True", + "discoveryVersion": "v1", + "baseUrl": "https://datastore.googleapis.com/", + "name": "datastore", + "parameters": { + "access_token": { + "description": "OAuth access token.", + "type": "string", + "location": "query" + }, + "prettyPrint": { + "description": "Returns response with indentations and line breaks.", + "default": "true", + "type": "boolean", + "location": "query" + }, + "key": { + "description": "API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.", + "type": "string", + "location": "query" + }, + "quotaUser": { + "description": "Available to use for quota purposes for server-side applications. Can be any arbitrary string assigned to a user, but should not exceed 40 characters.", + "type": "string", + "location": "query" + }, + "pp": { + "description": "Pretty-print response.", + "default": "true", + "type": "boolean", + "location": "query" + }, + "fields": { + "description": "Selector specifying which fields to include in a partial response.", + "type": "string", + "location": "query" + }, + "alt": { + "description": "Data format for response.", + "location": "query", + "enum": [ + "json", + "media", + "proto" + ], + "default": "json", + "enumDescriptions": [ + "Responses with Content-Type of application/json", + "Media download with context-dependent Content-Type", + "Responses with Content-Type of application/x-protobuf" + ], + "type": "string" + }, + "$.xgafv": { + "description": "V1 error format.", + "enum": [ + "1", + "2" + ], + "enumDescriptions": [ + "v1 error format", + "v2 error format" + ], + "type": "string", + "location": "query" + }, + "callback": { + "description": "JSONP", + "type": "string", + "location": "query" + }, + "oauth_token": { + "description": "OAuth 2.0 token for the current user.", + "type": "string", + "location": "query" + }, + "uploadType": { + "description": "Legacy upload protocol for media (e.g. \"media\", \"multipart\").", + "type": "string", + "location": "query" + }, + "bearer_token": { + "description": "OAuth bearer token.", + "type": "string", + "location": "query" + }, + "upload_protocol": { + "description": "Upload protocol for media (e.g. \"raw\", \"multipart\").", + "type": "string", + "location": "query" + } + }, + "documentationLink": "https://cloud.google.com/datastore/", + "ownerDomain": "google.com", + "batchPath": "batch", + "servicePath": "", + "ownerName": "Google", + "version": "v1", + "rootUrl": "https://datastore.googleapis.com/", + "kind": "discovery#restDescription" +} diff --git a/src/Datastore/DatastoreClient.php b/src/Datastore/DatastoreClient.php new file mode 100644 index 000000000000..4495659ffb32 --- /dev/null +++ b/src/Datastore/DatastoreClient.php @@ -0,0 +1,892 @@ + 'my-awesome-project' + * ]); + * + * $datastore = $cloud->datastore(); + * ``` + * + * ``` + * // The Datastore client can also be instantianted directly. + * use Google\Cloud\Datastore\DatastoreClient; + * + * $datastore = new DatastoreClient([ + * 'projectId' => 'my-awesome-project' + * ]); + * ``` + * + * ``` + * // Multi-tenant applications can supply a namespace ID. + * use Google\Cloud\ServiceBuilder; + * + * $cloud = new ServiceBuilder(); + * + * $datastore = $cloud->datastore([ + * 'projectId' => 'my-awesome-project', + * 'namespaceId' => 'my-application-namespace' + * ]); + * ``` + */ +class DatastoreClient +{ + use ClientTrait; + use DatastoreTrait; + + const FULL_CONTROL_SCOPE = 'https://www.googleapis.com/auth/datastore'; + + const DEFAULT_READ_CONSISTENCY = 'EVENTUAL'; + + /** + * @var ConnectionInterface + */ + protected $connection; + + /** + * @var Operation + */ + protected $operation; + + /** + * @var EntityMapper + */ + private $entityMapper; + + /** + * Create a Datastore client. + * + * @param array $config { + * Configuration Options. + * + * @type string $projectId The project ID from the Google Developer's + * Console. + * @type callable $authHttpHandler A handler used to deliver Psr7 + * requests specifically for authentication. + * @type callable $httpHandler A handler used to deliver Psr7 requests. + * @type string $keyFile The contents of the service account + * credentials .json file retrieved from the Google Developers + * Console. + * @type string $keyFilePath The full path to your service account + * credentials .json file retrieved from the Google Developers + * Console. + * @type int $retries Number of retries for a failed request. Defaults + * to 3. + * @type array $scopes Scopes to be used for the request. + * @type string $namespaceId Partitions data under a namespace. Useful for + * [Multitenant Projects](https://cloud.google.com/datastore/docs/concepts/multitenancy). + * } + * @throws \InvalidArgumentException + */ + public function __construct(array $config = []) + { + $config = $config + [ + 'namespaceId' => null + ]; + + if (!isset($config['scopes'])) { + $config['scopes'] = [self::FULL_CONTROL_SCOPE]; + } + + $this->connection = new Rest($this->configureAuthentication($config)); + $this->entityMapper = new EntityMapper($this->projectId, true); + $this->operation = new Operation( + $this->connection, + $this->projectId, + $config['namespaceId'], + $this->entityMapper + ); + } + + /** + * Create a single Key instance + * + * Example: + * ``` + * $key = $datastore->key('Person', 'Bob'); + * ``` + * + * ``` + * // To override the internal detection of identifier type, you can specify + * // which type to use. + * + * $key = $datastore->key('Robots', '1337', [ + * 'identifierType' => Key::TYPE_NAME + * ]); + * ``` + * + * @see https://cloud.google.com/datastore/reference/rest/v1/Key Key + * @see https://cloud.google.com/datastore/reference/rest/v1/Key#PathElement PathElement + * + * @param string $kind The kind. + * @param string|int $identifier The ID or name. + * @param array $options { + * Configuration Options + * + * @type string $identifierType If omitted, type will be determined + * internally. In cases where any ambiguity can be expected (i.e. + * if you want to create keys with `name` but your values may + * pass PHP's `is_numeric()` check), this value may be + * explicitly set using `Key::TYPE_ID` or `Key::TYPE_NAME`. + * } + * @return Key + */ + public function key($kind, $identifier = null, array $options = []) + { + return $this->operation->key($kind, $identifier, $options); + } + + /** + * Create multiple keys with the same configuration. + * + * When inserting multiple entities, creating a set of keys at once can be + * useful. By defining the Key's kind and any ancestors up front, and + * allowing Cloud Datastore to allocate IDs, you can be sure that your + * entity identity and ancestry are correct and that there will be no + * collisions during the insert operation. + * + * Example: + * ``` + * $keys = $datastore->keys('Person', [ + * 'number' => 10 + * ]); + * ``` + * + * ``` + * // Ancestor paths can be specified + * $keys = $datastore->keys('Person', [ + * 'ancestors' => [ + * ['kind' => 'Person', 'name' => 'Grandpa Joe'], + * ['kind' => 'Person', 'name' => 'Dad Mike'] + * ], + * 'number' => 3 + * ]); + * ``` + * + * @see https://cloud.google.com/datastore/reference/rest/v1/Key Key + * @see https://cloud.google.com/datastore/reference/rest/v1/Key#PathElement PathElement + * + * @param string $kind The kind to use in the final path element. + * @param array $options { + * Configuration Options + * + * @type array[] $ancestors An array of + * [PathElement](https://cloud.google.com/datastore/reference/rest/v1/Key#PathElement) arrays. Use to + * create [ancestor paths](https://cloud.google.com/datastore/docs/concepts/entities#ancestor_paths). + * @type int $number The number of keys to generate. + * @type string|int $id The ID for the last pathElement. + * @type string $name The Name for the last pathElement. + * } + * @return Key[] + */ + public function keys($kind, array $options = []) + { + return $this->operation->keys($kind, $options); + } + + /** + * Create an entity. + * + * This method does not execute any service requests. + * + * Entities are created with a Datastore Key, or by specifying a Kind. Kinds + * are only allowed for insert operations. For any other case, you must + * specify a complete key. If a kind is given, an ID will be automatically + * allocated for the entity upon insert. Additionally, if your entity + * requires a complex key elementPath, you must create the key separately. + * + * In complex applications you may want to create your own entity types. + * Google Cloud PHP supports subclassing of {@see Google\Cloud\Datastore\Entity}. + * If the name of a subclass of Entity is given in the options array, an + * entity will be created with that class rather than the default class. + * + * Example: + * ``` + * $key = $datastore->key('Person', 'Bob'); + * $entity = $datastore->entity($key, [ + * 'firstName' => 'Bob', + * 'lastName' => 'Testguy' + * ]); + * ``` + * + * ``` + * // Both of the following has the identical effect as the previous example. + * $entity = $datastore->entity($key); + * + * $entity['firstName'] = 'Bob'; + * $entity['lastName'] = 'Testguy'; + * + * $entity = $datastore->entity($key); + * + * $entity->firstName = 'Bob'; + * $entity->lastName = 'Testguy'; + * ``` + * + * ``` + * // Entities can be created with a Kind only, for inserting into datastore + * $entity = $datastore->entity('Person'); + * ``` + * + * ``` + * // Entities can be custom classes extending the built-in Entity class. + * class Person extends Google\Cloud\Datastore\Entity + * {} + * + * $person = $datastore->entity('Person', [ 'firstName' => 'Bob'], [ + * 'className' => Person::class + * ]); + * + * echo get_class($person); // `Person` + * ``` + * + * ``` + * // If you wish to exclude certain properties from datastore indexes, + * // property names may be supplied in the method $options: + * + * $entity = $datastore->entity('Person', [ + * 'firstName' => 'Bob', + * 'dateOfBirth' => new DateTime('January 31, 1969') + * ], [ + * 'excludeFromIndexes' => [ + * 'dateOfBirth' + * ] + * ]); + * ``` + * + * @see https://cloud.google.com/datastore/reference/rest/v1/Entity Entity + * + * @param Key|string $key The key used to identify the record, or a string $kind. + * @param array $entity The data to fill the entity with. + * @param array $options { + * Configuration Options + * + * @type string $className The name of a class extending {@see Google\Cloud\Datastore\Entity}. + * If provided, an instance of that class will be returned instead of Entity. + * If not set, {@see Google\Cloud\Datastore\Entity} will be used. + * @type array $excludeFromIndexes A list of entity keys to exclude from + * datastore indexes. + * } + * @return Entity + */ + public function entity($key, array $entity = [], array $options = []) + { + return $this->operation->entity($key, $entity, $options); + } + + /** + * Allocates an available ID to a given incomplete key + * + * Key MUST be in an incomplete state (i.e. including a kind but not an ID + * or name in its final pathElement). + * + * This method will execute a service request. + * + * Example: + * ``` + * $key = $datastore->key('Person'); + * $keyWithAllocatedId = $datastore->allocateId($key); + * ``` + * + * @see https://cloud.google.com/datastore/reference/rest/v1/projects/allocateIds allocateIds + * + * @param Key $key The incomplete key. + * @param array $options Configuration options. + * @return Key + */ + public function allocateId(Key $key, array $options = []) + { + $res = $this->allocateIds([$key], $options); + return $res[0]; + } + + /** + * Allocate available IDs to a set of keys + * + * Keys MUST be in an incomplete state (i.e. including a kind but not an ID + * or name in their final pathElement). + * + * This method will execute a service request. + * + * Example: + * ``` + * $keys = [ + * $datastore->key('Person'), + * $datastore->key('Person') + * ]; + * + * $keysWithAllocatedIds = $datastore->allocateIds($keys); + * ``` + * + * @see https://cloud.google.com/datastore/reference/rest/v1/projects/allocateIds allocateIds + * + * @param Key[] $keys The incomplete keys. + * @param array $options Configuration options. + * @return Key[] + */ + public function allocateIds(array $keys, array $options = []) + { + return $this->operation->allocateIds($keys, $options); + } + + /** + * Create a Transaction + * + * Example: + * ``` + * $transaction = $datastore->transaction(); + * ``` + * + * @see https://cloud.google.com/datastore/docs/concepts/transactions Datastore Transactions + * + * @param array $options Configuration options. + * @return Transaction + */ + public function transaction(array $options = []) + { + $res = $this->connection->beginTransaction($options + [ + 'projectId' => $this->projectId + ]); + + return new Transaction( + clone $this->operation, + $this->projectId, + $res['transaction'] + ); + } + + /** + * Insert an entity + * + * An entity with incomplete keys will be allocated an ID prior to insertion. + * + * Insert by this method is non-transactional. If you need transaction + * support, use {@see Google\Cloud\Datastore\Transaction::insert()}. + * + * Example: + * ``` + * $key = $datastore->key('Person', 'Bob'); + * $entity = $datastore->entity($key, ['firstName' => 'Bob']); + * + * $datastore->insert($entity); + * ``` + * + * @param Entity $entity The entity to be inserted. + * @param array $options Configuration options. + * @return string The entity version. + * @throws DomainException If a conflict occurs, fail. + */ + public function insert(Entity $entity, array $options = []) + { + $res = $this->insertBatch([$entity], $options); + return $this->parseSingleMutationResult($res); + } + + /** + * Insert multiple entities + * + * Any entity with incomplete keys will be allocated an ID prior to insertion. + * + * Insert by this method is non-transactional. If you need transaction + * support, use {@see Google\Cloud\Datastore\Transaction::insertBatch()}. + * + * Example: + * ``` + * + * $entities = [ + * $datastore->entity('Person', ['firstName' => 'Bob']), + * $datastore->entity('Person', ['firstName' => 'John']) + * ]; + * + * $datastore->insertBatch($entities); + * ``` + * + * @param Entity[] $entities The entities to be inserted. + * @param array $options Configuration options. + * @return array [Response Body](https://cloud.google.com/datastore/reference/rest/v1/projects/commit#response-body) + */ + public function insertBatch(array $entities, array $options = []) + { + $entities = $this->operation->allocateIdsToEntities($entities); + $this->operation->mutate('insert', $entities, Entity::class); + return $this->operation->commit($options); + } + + /** + * Update an entity + * + * Please note that updating a record in Cloud Datastore will replace the + * existing record. Adding, editing or removing a single property is only + * possible by first retrieving the entire entity in its existing state. + * + * Update by this method is non-transactional. If you need transaction + * support, use {@see Google\Cloud\Datastore\Transaction::update()}. + * + * Example: + * ``` + * $entity = $datastore->lookup($datastore->key('Person', 'Bob')); + * $entity['firstName'] = 'John'; + * + * $datastore->update($entity); + * ``` + * + * @param Entity $entity The entity to be updated. + * @param array $options { + * Configuration Options + * + * @type bool $allowOverwrite Set to `false` by default. Entities must + * be updated as an entire resource. Patch operations are not + * supported. Because entities can be created manually, or + * obtained by a lookup or query, it is possible to accidentally + * overwrite an existing record with a new one when manually + * creating an entity. To provide additional safety, this flag + * must be set to `true` in order to update a record when the + * entity provided was not obtained through a lookup or query. + * } + * @return string The entity version. + * @throws DomainException If a conflict occurs, fail. + */ + public function update(Entity $entity, array $options = []) + { + $res = $this->updateBatch([$entity], $options); + return $this->parseSingleMutationResult($res); + } + + /** + * Update multiple entities + * + * Please note that updating a record in Cloud Datastore will replace the + * existing record. Adding, editing or removing a single property is only + * possible by first retrieving the entire entity in its existing state. + * + * Update by this method is non-transactional. If you need transaction + * support, use {@see Google\Cloud\Datastore\Transaction::updateBatch()}. + * + * Example: + * ``` + * $entities[0]['firstName'] = 'Bob'; + * $entities[1]['firstName'] = 'John'; + * + * $datastore->updateBatch($entities); + * ``` + * + * @param Entity[] $entities The entities to be updated. + * @param array $options { + * Configuration Options + * + * @type bool $allowOverwrite Set to `false` by default. Entities must + * be updated as an entire resource. Patch operations are not + * supported. Because entities can be created manually, or + * obtained by a lookup or query, it is possible to accidentally + * overwrite an existing record with a new one when manually + * creating an entity. To provide additional safety, this flag + * must be set to `true` in order to update a record when the + * entity provided was not obtained through a lookup or query. + * } + * @return array [Response Body](https://cloud.google.com/datastore/reference/rest/v1/projects/commit#response-body) + */ + public function updateBatch(array $entities, array $options = []) + { + $options += [ + 'allowOverwrite' => false + ]; + + $this->operation->checkOverwrite($entities, $options['allowOverwrite']); + $this->operation->mutate('update', $entities, Entity::class); + return $this->operation->commit($options); + } + + /** + * Upsert an entity + * + * Upsert will create a record if one does not already exist, or overwrite + * existing record if one already exists. + * + * Please note that upserting a record in Cloud Datastore will replace the + * existing record, if one exists. Adding, editing or removing a single + * property is only possible by first retrieving the entire entity in its + * existing state. + * + * Upsert by this method is non-transactional. If you need transaction + * support, use {@see Google\Cloud\Datastore\Transaction::upsert()}. + * + * Example: + * ``` + * $key = $datastore->key('Person', 'Bob']); + * $entity = $datastore->entity($key, ['firstName' => 'Bob']); + * + * $datastore->upsert($entity); + * ``` + * + * @param Entity $entity The entity to be upserted. + * @param array $options Configuration options. + * @return string The entity version. + * @throws DomainException If a conflict occurs, fail. + */ + public function upsert(Entity $entity, array $options = []) + { + $res = $this->upsertBatch([$entity], $options); + return $this->parseSingleMutationResult($res); + } + + /** + * Upsert multiple entities + * + * Upsert will create a record if one does not already exist, or overwrite + * an existing record if one already exists. + * + * Please note that upserting a record in Cloud Datastore will replace the + * existing record, if one exists. Adding, editing or removing a single + * property is only possible by first retrieving the entire entity in its + * existing state. + * + * Upsert by this method is non-transactional. If you need transaction + * support, use {@see Google\Cloud\Datastore\Transaction::upsertBatch()}. + * + * Example: + * ``` + * $keys = [ + * $datastore->key('Person', 'Bob'), + * $datastore->key('Person', 'John') + * ]; + * + * $entities = [ + * $datastore->entity($key[0], ['firstName' => 'Bob']), + * $datastore->entity($key[1], ['firstName' => 'John']) + * ]; + * + * $datastore->upsertBatch($entities); + * ``` + * + * @param Entity[] $entities The entities to be upserted. + * @param array $options Configuration options. + * @return array [Response Body](https://cloud.google.com/datastore/reference/rest/v1/projects/commit#response-body) + */ + public function upsertBatch(array $entities, array $options = []) + { + $this->operation->mutate('upsert', $entities, Entity::class); + return $this->operation->commit($options); + } + + /** + * Delete an entity + * + * Deletion by this method is non-transactional. If you need transaction + * support, use {@see Google\Cloud\Datastore\Transaction::delete()}. + * + * Example: + * ``` + * $key = $datastore->key('Person', 'Bob'); + * + * $datastore->delete($key); + * ``` + * + * @param Key $key The identifier to delete. + * @param array $options { + * Configuration options + * + * @type string $baseVersion Provides concurrency control. The version + * of the entity that this mutation is being applied to. If this + * does not match the current version on the server, the mutation + * conflicts. + * } + * @return string The updated entity version number. + * @throws DomainException If a conflict occurs, fail. + */ + public function delete(Key $key, array $options = []) + { + $res = $this->deleteBatch([$key], $options); + return $this->parseSingleMutationResult($res); + } + + /** + * Delete multiple entities + * + * Deletion by this method is non-transactional. If you need transaction + * support, use {@see Google\Cloud\Datastore\Transaction::deleteBatch()}. + * + * Example: + * ``` + * $keys = [ + * $datastore->key('Person', 'Bob'), + * $datastore->key('Person', 'John') + * ]; + * + * $datastore->deleteBatch($keys); + * ``` + * + * @param Key[] $keys The identifiers to delete. + * @param array $options { + * Configuration options + * + * @type string $baseVersion Provides concurrency control. The version + * of the entity that this mutation is being applied to. If this + * does not match the current version on the server, the mutation + * conflicts. + * } + * @return array [Response Body](https://cloud.google.com/datastore/reference/rest/v1/projects/commit#response-body) + */ + public function deleteBatch(array $keys, array $options = []) + { + $options += [ + 'baseVersion' => null + ]; + + $this->operation->mutate('delete', $keys, Key::class, $options['baseVersion']); + return $this->operation->commit($options); + } + + /** + * Retrieve an entity from the datastore + * + * To lookup an entity inside a transaction, use + * {@see Google\Cloud\Datastore\Transaction::lookup()}. + * + * Example: + * ``` + * $key = $datastore->key('Person', 'Bob'); + * + * $entity = $datastore->lookup($key); + * if (!is_null($entity)) { + * echo $entity['firstName']; // 'Bob' + * } + * ``` + * + * @param Key $key $key The identifier to use to locate a desired entity. + * @param array $options { + * Configuration Options + * + * @type string $readConsistency See + * [ReadConsistency](https://cloud.google.com/datastore/reference/rest/v1/ReadOptions#ReadConsistency). + * "EVENTUAL" by default. + * @type string $className The name of the class to return results as. + * Must be a subclass of {@see Google\Cloud\Datastore\Entity}. + * If not set, {@see Google\Cloud\Datastore\Entity} will be used. + * } + * @return Entity|null + */ + public function lookup(Key $key, array $options = []) + { + $res = $this->lookupBatch([$key], $options); + + return (isset($res['found'][0])) + ? $res['found'][0] + : null; + } + + /** + * Get multiple entities + * + * To lookup entities inside a transaction, use + * {@see Google\Cloud\Datastore\Transaction::lookupBatch()}. + * + * Example: + * ``` + * $keys = [ + * $datastore->key('Person', 'Bob'), + * $datastore->key('Person', 'John') + * ]; + * + * $entities = $datastore->lookup($keys); + * + * foreach ($entities['found'] as $entity) { + * echo $entity['firstName']; + * } + * ``` + * + * @param Key[] $key The identifiers to look up. + * @param array $options { + * Configuration Options + * + * @type string $readConsistency See + * [ReadConsistency](https://cloud.google.com/datastore/reference/rest/v1/ReadOptions#ReadConsistency). + * "EVENTUAL" by default. + * @type string|array $className If a string, the name of the class to return results as. + * Must be a subclass of {@see Google\Cloud\Datastore\Entity}. + * If not set, {@see Google\Cloud\Datastore\Entity} will be used. + * If an array is given, it must be an associative array, where + * the key is a Kind and the value is the name of a subclass of + * {@see Google\Cloud\Datastore\Entity}. + * } + * @return array Returns an array with keys [`found`, `missing`, and `deferred`]. + * Members of `found` will be instance of + * {@see Google\Cloud\Datastore\Entity}. Members of `missing` and + * `deferred` will be instance of {@see Google\Cloud\Datastore\Key}. + */ + public function lookupBatch(array $keys, array $options = []) + { + return $this->operation->lookup($keys, $options); + } + + /** + * Create a Query + * + * The Query class can be used as a builder, or it can accept a query + * representation at instantiation. + * + * Example: + * ``` + * $query = $datastore->query(); + * ``` + * + * @param array $options { + * Query Options + * + * @type array $query [Query](https://cloud.google.com/datastore/reference/rest/v1/projects/runQuery#query) + * } + * @return Query + */ + public function query(array $options = []) + { + return new Query($this->entityMapper, $options); + } + + /** + * Create a GqlQuery + * + * Example: + * ``` + * $query = $datastore->gqlQuery('SELECT * FROM Companies'); + * ``` + * + * ``` + * // Literals must be provided as bound parameters by default: + * $query = $datastore->gqlQuery('SELECT * FROM Companies WHERE companyName = @companyName', [ + * 'bindings' => [ + * 'companyName' => 'Bob' + * ] + * ]); + * ``` + * + * ``` + * // Positional binding is also supported: + * $query = $datastore->gqlQuery('SELECT * FROM Companies WHERE companyName = @1 LIMIT 1', [ + * 'bindings' => [ + * 'Google' + * ] + * ]); + * ``` + * + * ``` + * // While not recommended, you can use literals in your query string: + * $query = $datastore->gqlQuery("SELECT * FROM Companies WHERE companyName = 'Google'", [ + * 'allowLiterals' => true + * ]); + * ``` + * + * @param string $query The [GQL Query](https://cloud.google.com/datastore/docs/apis/gql/gql_reference) string. + * @param array $options { + * Configuration Options + * + * @type bool $allowLiterals Whether literal values will be allowed in + * the query string. This is false by default, and parameter + * binding is strongly encouraged over literals. + * @type array $bindings An array of values to bind to the query string. + * Queries using Named Bindings should provide a key/value set, + * while queries using Positional Bindings must provide a simple + * array. + * Applications with no need for multitenancy should not set this value. + * @type string $readConsistency See + * [ReadConsistency](https://cloud.google.com/datastore/reference/rest/v1/ReadOptions#ReadConsistency). + * "EVENTUAL" by default. + * } + * @return GqlQuery + */ + public function gqlQuery($query, array $options = []) + { + return new GqlQuery($this->entityMapper, $query, $options); + } + + /** + * Run a query and return entities + * + * To query datastore inside a transaction, use + * {@see Google\Cloud\Datastore\Transaction::runQuery()}. + * + * Example: + * ``` + * $result = $datastore->runQuery($query); + * + * foreach ($result as $entity) { + * echo $entity['firstName']; + * } + * ``` + * + * @param QueryInterface $query A query object. + * @param array $options { + * Configuration Options + * + * @type string $className The name of the class to return results as. + * Must be a subclass of {@see Google\Cloud\Datastore\Entity}. + * If not set, {@see Google\Cloud\Datastore\Entity} will be used. + * @type string $readConsistency See + * [ReadConsistency](https://cloud.google.com/datastore/reference/rest/v1/ReadOptions#ReadConsistency). + * "EVENTUAL" by default. + * } + * @return \Generator + */ + public function runQuery(QueryInterface $query, array $options = []) + { + return $this->operation->runQuery($query, $options); + } + + /** + * Handle mutation results + * + * @codingStandardsIgnoreStart + * @param array $res [MutationResult](https://cloud.google.com/datastore/reference/rest/v1/projects/commit#MutationResult) + * @return string + * @throws DomainException + * @codingStandardsIgnoreEnd + */ + private function parseSingleMutationResult(array $res) + { + $mutationResult = $res['mutationResults'][0]; + + if (isset($mutationResult['conflictDetected'])) { + throw new DomainException( + 'A conflict was detected in the mutation. ' . + 'The operation failed.' + ); + } + + return $mutationResult['version']; + } +} diff --git a/src/Datastore/DatastoreTrait.php b/src/Datastore/DatastoreTrait.php new file mode 100644 index 000000000000..01bae5bd6c8d --- /dev/null +++ b/src/Datastore/DatastoreTrait.php @@ -0,0 +1,53 @@ + $projectId, + 'namespaceId' => $namespaceId + ]); + } + + /** + * Determine whether given array is associative + * + * @param array $value + * @return bool + */ + private function isAssoc(array $value) + { + return array_keys($value) !== range(0, count($value) - 1); + } +} diff --git a/src/Datastore/Entity.php b/src/Datastore/Entity.php new file mode 100644 index 000000000000..a13b70cc3fce --- /dev/null +++ b/src/Datastore/Entity.php @@ -0,0 +1,318 @@ +datastore(); + * + * $key = $datastore->key('Person', 'Bob'); + * $entity = $datastore->entity($key, [ + * 'firstName' => 'Bob', + * 'lastName' => 'Testguy' + * ]); + * + * echo $entity['firstName']; // 'Bob' + * $entity['location'] = 'Detroit, MI'; + * ``` + */ +class Entity implements ArrayAccess +{ + use DatastoreTrait; + + /** + * @var Key + */ + private $key; + + /** + * @var array + */ + private $entity; + + /** + * @var array + */ + private $options; + + /** + * @param Key $key The Entity's Key, defining its unique identifier. + * @param array $entity The entity body. + * @param array $options { + * Configuration Options + * + * @type string $cursor Set only when the entity is obtained by a query + * result. If set, the entity cursor can be retrieved from + * {@see Google\Cloud\Datastore\Entity::cursor()}. + * @type string $baseVersion Set only when the entity is obtained by a + * query result. If set, the entity cursor can be retrieved from + * {@see Google\Cloud\Datastore\Entity::baseVersion()}. + * @type array $excludeFromIndexes A list of entity keys to exclude from + * datastore indexes. + * @type bool $populatedByService Indicates whether the entity was + * created as the result of a service request. + * } + * @throws InvalidArgumentException + */ + public function __construct(Key $key, array $entity = [], array $options = []) + { + $this->key = $key; + $this->entity = $entity; + $this->options = $options + [ + 'cursor' => null, + 'baseVersion' => null, + 'populatedByService' => false, + 'excludeFromIndexes' => [] + ]; + } + + /** + * Get the entity data + * + * Example: + * ``` + * $data = $entity->get(); + * ``` + * + * @return array + */ + public function get() + { + return $this->entity; + } + + /** + * Set the entity data + * + * Calling this method replaces the entire entity body. To add or modify a + * single value on the entity, use the array syntax for assignment. + * + * Example: + * ``` + * $entity->set([ + * 'firstName' => 'Dave' + * ]); + * ``` + * + * @param array $entity The new entity body. + * @return void + */ + public function set(array $entity) + { + $this->entity = $entity; + } + + /** + * Get the Entity Key + * + * Example: + * ``` + * $key = $entity->key(); + * ``` + * + * @return Key + */ + public function key() + { + return $this->key; + } + + /** + * Fetch the cursor + * + * This is only set when the entity was obtained from a query result. It + * can be used to manually paginate results. + * + * Example: + * ``` + * $cursor = $entity->cursor(); + * ``` + * + * @return string|null + */ + public function cursor() + { + return $this->options['cursor']; + } + + /** + * Fetch the baseVersion + * + * This is only set when the entity was obtained from a query result. It + * is used for concurrency control internally. + * + * Example: + * ``` + * $baseVersion = $entity->baseVersion(); + * ``` + * + * @return string|null + */ + public function baseVersion() + { + return $this->options['baseVersion']; + } + + /** + * Indicate whether the entity was created as the result of an API call. + * + * Example: + * ``` + * $populatedByService = $entity->populatedByService(); + * ``` + * + * @return bool + */ + public function populatedByService() + { + return $this->options['populatedByService']; + } + + /** + * A list of entity properties to exclude from datastore indexes. + * + * Example: + * ``` + * $entity['birthDate'] = new DateTime('December 31, 1969'); + * $entity->setExcludeFromIndexes([ + * 'birthDate' + * ]); + * ``` + * + * @param array $properties A list of properties to exclude from indexes. + * @return void + */ + public function setExcludeFromIndexes(array $properties) + { + $this->options['excludeFromIndexes'] = $properties; + } + + /** + * Return a list of properties excluded from datastore indexes + * + * Example: + * ``` + * print_r($entity->excludedProperties()); + * ``` + * + * @return array + */ + public function excludedProperties() + { + return $this->options['excludeFromIndexes']; + } + + /** + * @param string $key The value name. + * @param mixed $value The value. + * @return void + * @access private + */ + public function offsetSet($key, $val) + { + $this->entity[$key] = $val; + } + + /** + * @param string $key the value to check. + * @return bool + * @access private + */ + public function offsetExists($key) + { + return isset($this->entity[$key]); + } + + /** + * @param string $key the value to remove. + * @return void + * @access private + */ + public function offsetUnset($key) + { + unset($this->entity[$key]); + } + + /** + * @param string $key the value to retrieve. + * @return mixed + * @access private + */ + public function offsetGet($key) + { + return isset($this->entity[$key]) + ? $this->entity[$key] + : null; + } + + /** + * @param string $property + * @return mixed + * @access private + */ + public function __get($property) + { + return $this->offsetExists($property) ? $this->offsetGet($property) : null; + } + + /** + * @param string $property + * @param mixed $value + * @return void + * @access private + */ + public function __set($property, $value) + { + $this->offsetSet($property, $value); + } + + /** + * @param string $property + * @return void + * @access private + */ + public function __unset($property) + { + if ($this->offsetExists($property)) { + $this->offsetUnset($property); + } + } + + /** + * @param string $property + * @return bool + * @access private + */ + public function __isset($property) + { + return $this->offsetExists($property) && $this->offsetGet($property) !== null; + } +} diff --git a/src/Datastore/EntityMapper.php b/src/Datastore/EntityMapper.php new file mode 100644 index 000000000000..52c044163e3d --- /dev/null +++ b/src/Datastore/EntityMapper.php @@ -0,0 +1,393 @@ +projectId = $projectId; + $this->encode = $encode; + } + + /** + * Map a lookup or query result to a set of properties + * + * @param array $entityData The incoming entity data + * @return array + */ + public function responseToProperties(array $entityData) + { + $props = []; + + foreach ($entityData as $key => $property) { + $type = key($property); + + $props[$key] = $this->convertValue($type, $property[$type]); + } + + return $props; + } + + /** + * Get a list of properties excluded from datastore indexes + * + * @param array $entityData The incoming entity data + * @return array + */ + public function responseToExcludeFromIndexes(array $entityData) + { + $excludes = []; + + foreach ($entityData as $property) { + $type = key($property); + + if (isset($property['excludeFromIndexes']) && $property['excludeFromIndexes']) { + $excludes[] = $key; + } + } + + return $excludes; + } + + /** + * Translate an Entity to a datastore representation. + * + * @param Entity $entity The input entity. + * @return array A Datastore [Entity](https://cloud.google.com/datastore/reference/rest/v1/Entity) + */ + public function objectToRequest(Entity $entity) + { + $data = $entity->get(); + + $properties = []; + foreach ($data as $key => $value) { + $exclude = in_array($key, $entity->excludedProperties()); + + $properties[$key] = $this->valueObject( + $value, + $exclude + ); + } + + return array_filter([ + 'key' => $entity->key(), + 'properties' => $properties + ]); + } + + /** + * Convert a Datastore value object to a simple value + * + * @param string $type The value type + * @param mixed $value The value + * @return mixed + */ + public function convertValue($type, $value) + { + $result = null; + + switch ($type) { + case 'timestampValue': + $result = new \DateTimeImmutable($value); + + break; + + case 'keyValue': + $namespaceId = (isset($value['partitionId']['namespaceId'])) + ? $value['partitionId']['namespaceId'] + : null; + + $result = new Key($this->projectId, [ + 'path' => $value['path'], + 'namespaceId' => $namespaceId + ]); + + break; + + case 'geoPointValue': + $value += [ + 'latitude' => null, + 'longitude' => null + ]; + + $result = new GeoPoint($value['latitude'], $value['longitude']); + + break; + + case 'entityValue': + $props = $this->responseToProperties($value['properties']); + + if (isset($value['key'])) { + $namespaceId = (isset($value['key']['partitionId']['namespaceId'])) + ? $value['key']['partitionId']['namespaceId'] + : null; + + $key = new Key($this->projectId, [ + 'path' => $value['key']['path'], + 'namespaceId' => $namespaceId + ]); + + $result = new Entity($key, $props, [ + 'populatedByService' => true + ]); + } else { + $result = []; + + foreach ($value['properties'] as $key => $property) { + $type = key($property); + + $result[$key] = $this->convertValue($type, $property[$type]); + } + } + + break; + + case 'doubleValue': + $result = (float) $value; + + break; + + case 'integerValue': + $result = (int) $value; + + break; + + case 'arrayValue': + $result = []; + + foreach ($value['values'] as $val) { + $type = key($val); + + $result[] = $this->convertValue($type, $val[$type]); + } + + break; + + default: + $result = $value; + break; + } + + + return $result; + } + + /** + * Format values for the API + * + * @param mixed $value + * @param bool $exclude If true, value will be excluded from datastore indexes. + * @return array + */ + public function valueObject($value, $exclude = false) + { + switch (gettype($value)) { + case 'boolean': + $propertyValue = [ + 'booleanValue' => $value + ]; + + break; + + case 'integer': + $propertyValue = [ + 'integerValue' => $value + ]; + + break; + + case 'double': + $propertyValue = [ + 'doubleValue' => $value + ]; + + break; + + case 'string': + $propertyValue = [ + 'stringValue' => $value + ]; + + break; + + case 'array': + if (!empty($value) && $this->isAssoc($value)) { + $propertyValue = $this->convertArrayToEntityValue($value); + } else { + $propertyValue = $this->convertArrayToArrayValue($value); + } + + break; + + case 'object': + $propertyValue = $this->objectProperty($value); + break; + + case 'resource': + $content = stream_get_contents($value); + + $propertyValue = [ + 'blobValue' => ($this->encode) + ? base64_encode($content) + : $content + ]; + break; + + case 'NULL': + $propertyValue = [ + 'nullValue' => null + ]; + break; + + //@codeCoverageIgnoreStart + case 'unknown type': + throw new InvalidArgumentException(sprintf( + 'Unknown type for `%s', + $content + )); + break; + + default: + throw new InvalidArgumentException(sprintf( + 'Invalid type for `%s', + $content + )); + break; + //@codeCoverageIgnoreEnd + } + + if ($exclude) { + $propertyValue['excludeFromIndexes'] = true; + } + + return $propertyValue; + } + + /** + * Convert different object types to API values + * + * @todo add middleware + * + * @param mixed $value + * @return array + */ + public function objectProperty($value) + { + switch (true) { + case $value instanceof \DateTimeInterface: + return [ + 'timestampValue' => $value->format(\DateTime::RFC3339) + ]; + + break; + + case $value instanceof Key: + return [ + 'keyValue' => $value->keyObject() + ]; + + break; + + case $value instanceof GeoPoint: + return [ + 'geoPointValue' => $value->point() + ]; + + break; + + case $value instanceof Entity: + return [ + 'entityValue' => $this->objectToRequest($value) + ]; + + default: + throw new InvalidArgumentException( + sprintf('Value of type `%s` could not be serialized', get_class($value)) + ); + + break; + } + } + + /** + * Convert a non-associative array to a datastore arrayValue type + * + * @param array $value The input array + * @return array The arrayValue property + */ + private function convertArrayToArrayValue(array $value) + { + $values = []; + foreach ($value as $val) { + $values[] = $this->valueObject($val); + } + + return [ + 'arrayValue' => [ + 'values' => $values + ] + ]; + } + + /** + * Convert an associative array to a datastore entityValue type + * + * @param array $value The input array + * @return array The entityValue property + */ + private function convertArrayToEntityValue(array $value) + { + $properties = []; + foreach ($value as $key => $val) { + $properties[$key] = $this->valueObject($val); + } + + return [ + 'entityValue' => [ + 'properties' => $properties + ] + ]; + } +} diff --git a/src/Datastore/GeoPoint.php b/src/Datastore/GeoPoint.php new file mode 100644 index 000000000000..84d6f3cadbf1 --- /dev/null +++ b/src/Datastore/GeoPoint.php @@ -0,0 +1,181 @@ +setLatitude($latitude); + $this->setLongitude($longitude); + } + + /** + * Get the latitude + * + * Example: + * ``` + * $latitude = $point->latitude(); + * ``` + * + * @return float + */ + public function latitude() + { + $this->checkContext('latitude', func_get_args()); + return $this->latitude; + } + + /** + * Set the longitude + * + * Non-numeric values will result in an exception + * + * Example: + * ``` + * $point->setLatitude(42.279594); + * ``` + * + * @param int|float $latitude The new latitude + * @return GeoPoint + * @throws InvalidArgumentException + */ + public function setLatitude($latitude) + { + if (is_numeric($latitude)) { + $latitude = (float) $latitude; + } else { + throw new InvalidArgumentException('Given latitude must be a float'); + } + + $this->latitude = $latitude; + + return $this; + } + + /** + * Get the longitude + * + * Example: + * ``` + * $longitude = $point->longitude(); + * ``` + * + * @return float + */ + public function longitude() + { + $this->checkContext('longitude', func_get_args()); + return $this->longitude; + } + + /** + * Set the longitude + * + * Non-numeric values will result in an exception. + * + * Example: + * ``` + * $point->setLongitude(-83.732124); + * ``` + * + * @param float|int $longitude The new longitude value + * @return GeoPoint + * @throws InvalidArgumentException + */ + public function setLongitude($longitude) + { + if (is_numeric($longitude)) { + $longitude = (float) $longitude; + } else { + throw new InvalidArgumentException('Given longitude must be a float'); + } + + $this->longitude = $longitude; + + return $this; + } + + /** + * Return a GeoPoint + * + * Example: + * ``` + * $point = $point->point(); + * ``` + * + * @return array [LatLng](https://cloud.google.com/datastore/reference/rest/Shared.Types/LatLng) + */ + public function point() + { + return [ + 'latitude' => $this->latitude, + 'longitude' => $this->longitude + ]; + } + + /** + * Let people know if they accidentally use the getter in setter context. + * + * @param string $method the method name + * @param array $args The method arguments + * @throws InvalidArgumentException + * @return void + */ + private function checkContext($method, array $args) + { + if (count($args) > 0) { + throw new InvalidArgumentException(sprintf( + 'Calling method %s with arguments is unsupported.', + $method + )); + } + } +} diff --git a/src/Datastore/Key.php b/src/Datastore/Key.php new file mode 100644 index 000000000000..25816a4d6979 --- /dev/null +++ b/src/Datastore/Key.php @@ -0,0 +1,422 @@ +datastore(); + * + * $key = $datastore->key('Person', 'Bob'); + * ``` + * + * ``` + * // Keys with complex paths can be constructed by chaining method calls. + * + * $key = $datastore->key('Person', 'Bob'); + * $key->ancestor('Parents', 'Joe'); + * $key->ancestor('Grandparents', 'Barb'); + * ``` + * + * ``` + * // Path elements can also be appended, so long as the current last path + * // element contains a kind and identifier. + * + * $key = $datastore->key('Grandparents', 'Barb'); + * $key->pathElement('Parents', 'Joe'); + * $key->pathElement('Person'); + * $key->pathElement('Child', 'Dave'); // Error here. + * ``` + * + * @see https://cloud.google.com/datastore/reference/rest/v1/Key Key + */ +class Key implements JsonSerializable +{ + use DatastoreTrait; + + const TYPE_NAME = 'name'; + const TYPE_ID = 'id'; + + const STATE_COMPLETE = 'complete'; + const STATE_INCOMPLETE = 'incomplete'; + + /** + * @var string + */ + private $projectId; + + /** + * @var array + */ + private $path = []; + + /** + * @var array + */ + private $options; + + /** + * Create a Key. + * + * @param string $projectId The project ID. + * @param array $options { + * Configuration Options + * + * @type string $namespaceId Partitions data under a namespace. Useful for + * [Multitenant Projects](https://cloud.google.com/datastore/docs/concepts/multitenancy). + * Applications with no need for multitenancy should not set this value. + * @type array $path The initial Key path. + * } + */ + public function __construct($projectId, array $options = []) + { + $this->projectId = $projectId; + $this->options = $options + [ + 'path' => [], + 'namespaceId' => null + ]; + + if (is_array($this->options['path']) && !empty($this->options['path'])) { + $this->path = $this->normalizePath($this->options['path']); + } + + unset($this->options['path']); + } + + /** + * Add a path element to the end of the Key path + * + * If the previous pathElement is incomplete (has no name or ID specified), + * an `InvalidArgumentException` will be thrown. Once an incomplete + * pathElement is given, the key cannot be extended any further. + * + * Example: + * ``` + * $key->pathElement('Person', 'Jane'); + * ``` + * + * @see https://cloud.google.com/datastore/reference/rest/v1/Key#PathElement PathElement + * + * @param string $kind The kind. + * @param string|int $identifier The name or ID of the object. + * @param string $identifierType If omitted, the type will be determined + * internally. Setting this to either `Key::TYPE_ID` or + * `Key::TYPE_NAME` will force the pathElement identifier type. + * @return Key + * @throws InvalidArgumentException + */ + public function pathElement($kind, $identifier = null, $identifierType = null) + { + if (!empty($this->path) && $this->state() !== Key::STATE_COMPLETE) { + throw new InvalidArgumentException( + 'Cannot add pathElement because the previous element is missing an id or name' + ); + } + + $pathElement = $this->normalizeElement($kind, $identifier, $identifierType); + + $this->path[] = $pathElement; + + return $this; + } + + /** + * Add a path element to the beginning of the Key path. + * + * Example: + * ``` + * $key->ancestor('Person', 'Bob'); + * ``` + * + * @see https://cloud.google.com/datastore/reference/rest/v1/Key#PathElement PathElement + * + * @param string $kind The kind. + * @param string|int $identifier The name or ID of the object. + * @param string $identifierType If omitted, the type will be determined + * internally. Setting this to either `Key::TYPE_ID` or + * `Key::TYPE_NAME` will force the pathElement identifier type. + * @return Key + */ + public function ancestor($kind, $identifier, $identifierType = null) + { + $pathElement = $this->normalizeElement($kind, $identifier, $identifierType); + + array_unshift($this->path, $pathElement); + + return $this; + } + + /** + * Use another Key's path as the current Key's ancestor + * + * Given key path will be prepended to any path elements on the current key. + * + * Example: + * ``` + * $parent = $datastore->key('Person', 'Dad'); + * $key->ancestoryKey($parent); + * ``` + * + * @param Key $key The ancestor Key. + * @return Key + * @throws InvalidArgumentException + */ + public function ancestorKey(Key $key) + { + if ($key->state() !== self::STATE_COMPLETE) { + throw new InvalidArgumentException('Cannot use an incomplete key as an ancestor'); + } + + $path = $key->path(); + + $this->path = array_merge($path, $this->path); + + return $this; + } + + /** + * Check if the Key is considered Complete or Incomplete. + * + * Use `Key::STATE_COMPLETE` and `Key::STATE_INCOMPLETE` to check value. + * + * Example: + * ``` + * // An incomplete key does not have an ID on its last path element. + * $key = $datastore->key('parent', 1234) + * ->pathElement('child'); + * + * if ($key->state() === Key::STATE_INCOMPLETE) { + * echo 'Key is incomplete!'; + * } + * ``` + * + * ``` + * // A complete key has a kind and an identifier on each path element. + * $key = $datastore->key('parent', 1234) + * ->pathElement('child', 4321); + * + * if ($key->state() === Key::STATE_COMPLETE) { + * echo 'Key is complete!'; + * } + * ``` + * + * @return bool + */ + public function state() + { + $end = $this->pathEnd(); + return (isset($end['id']) || isset($end['name'])) + ? self::STATE_COMPLETE + : self::STATE_INCOMPLETE; + } + + /** + * Set the value of the last path element in a Key + * + * This method is used internally when IDs are allocated to existing instances + * of a Key. It should not generally be used externally. + * + * @param string $value The value of the ID or Name. + * @param string $type 'id' or 'name'. 'id' by default. + * @return void + * @access private + */ + public function setLastElementIdentifier($value, $type = Key::TYPE_ID) + { + $end = $this->pathEnd(); + $end[$type] = (string) $value; + + $elements = array_keys($this->path); + $lastElement = end($elements); + + $this->path[$lastElement] = $end; + } + + /** + * Get the key path + * + * Example: + * ``` + * $path = $key->path(); + * ``` + * + * @return array + */ + public function path() + { + return $this->path; + } + + /** + * Get the last pathElement in the key + * + * Example: + * ``` + * $lastPathElement = $key->pathEnd(); + * ``` + * + * @return array + */ + public function pathEnd() + { + $path = $this->path; + $end = end($path); + + return $end; + } + + /** + * Get the key object formatted for the datastore service. + * + * @access private + * @return array + */ + public function keyObject() + { + return [ + 'partitionId' => $this->partitionId($this->projectId, $this->options['namespaceId']), + 'path' => $this->path + ]; + } + + /** + * @access private + */ + public function jsonSerialize() + { + return $this->keyObject(); + } + + /** + * Determine the identifier type and return the valid pathElement + * + * @param string $kind the kind. + * @param mixed $identifier The ID or name. + * @param string $identifierType Either `id` or `name`. + * @return array + */ + private function normalizeElement($kind, $identifier, $identifierType) + { + $identifierType = $this->determineIdentifierType($identifier, $identifierType); + + $element = []; + $element['kind'] = $kind; + + if (!is_null($identifier)) { + $element[$identifierType] = $identifier; + } + + return $element; + } + + /** + * Determine whether the given identifier is an ID or a Name + * + * @param mixed $identifier The given value. + * @param string|null $identifierType If not null and allowed, this will be + * used as the type. If null, type will be inferred. + * @return string + * @throws InvalidArgumentException + */ + private function determineIdentifierType($identifier, $identifierType) + { + $allowedTypes = [self::TYPE_ID, self::TYPE_NAME]; + + if (!is_null($identifierType) && in_array($identifierType, $allowedTypes)) { + return $identifierType; + } elseif (!is_null($identifierType)) { + throw new InvalidArgumentException(sprintf( + 'Invalid identifier type %s', + $identifierType + )); + } + + if (is_numeric($identifier)) { + return self::TYPE_ID; + } + + return self::TYPE_NAME; + } + + /** + * Normalize the internal representation of a path + * + * @param array $path + * @return array + * @throws InvalidArgumentException + */ + private function normalizePath(array $path) + { + // If the path is associative (i.e. not nested), wrap it up. + if ($this->isAssoc($path)) { + $path = [$path]; + } + + $res = []; + foreach ($path as $index => $pathElement) { + if (!isset($pathElement['kind'])) { + throw new InvalidArgumentException('Each path element must contain a kind.'); + } + + $incomplete = (!isset($pathElement['id']) && !isset($pathElement['name'])); + if ($index < count($path) -1 && $incomplete) { + throw new InvalidArgumentException( + 'Only the final pathElement may omit a name or ID.' + ); + } + + if (isset($pathElement['id']) && !is_string($pathElement['id'])) { + $pathElement['id'] = (string) $pathElement['id']; + } + + $res[] = $pathElement; + } + + return $res; + } + + /** + * Represent the path as a string. + * + * @access private + */ + public function __toString() + { + $el = []; + foreach ($this->path as $element) { + $element = $element + [ + 'id' => null, + 'name' => null + ]; + $id = ($element['id']) ? $element['id'] : $element['name']; + $el[] = sprintf('[%s: %s]', $element['kind'], $id); + } + + return sprintf('[ %s ]', implode(', ', $el)); + } +} diff --git a/src/Datastore/Operation.php b/src/Datastore/Operation.php new file mode 100644 index 000000000000..584501db4d5f --- /dev/null +++ b/src/Datastore/Operation.php @@ -0,0 +1,678 @@ +connection = $connection; + $this->projectId = $projectId; + $this->namespaceId = $namespaceId; + $this->entityMapper = $entityMapper; + } + + /** + * Create a single Key instance + * + * @see https://cloud.google.com/datastore/reference/rest/v1/Key Key + * @see https://cloud.google.com/datastore/reference/rest/v1/Key#PathElement PathElement + * + * @param string $kind The kind. + * @param string|int $identifier The ID or name. + * @param array $options { + * Configuration Options + * + * @type string $identifierType If omitted, type will be determined + * internally. In cases where any ambiguity can be expected (i.e. + * if you want to create keys with `name` but your values may + * pass PHP's `is_numeric()` check), this value may be + * explicitly set using `Key::TYPE_ID` or `Key::TYPE_NAME`. + * } + * @return Key + */ + public function key($kind, $identifier = null, array $options = []) + { + $options += [ + 'identifierType' => null, + 'namespaceId' => $this->namespaceId + ]; + + $key = new Key($this->projectId, $options); + $key->pathElement($kind, $identifier, $options['identifierType']); + + return $key; + } + + /** + * Create multiple keys with the same configuration. + * + * When inserting multiple entities, creating a set of keys at once can be + * useful. By defining the Key's kind and any ancestors up front, and + * allowing Cloud Datastore to allocate IDs, you can be sure that your + * entity identity and ancestry are correct and that there will be no + * collisions during the insert operation. + * + * @see https://cloud.google.com/datastore/reference/rest/v1/Key Key + * @see https://cloud.google.com/datastore/reference/rest/v1/Key#PathElement PathElement + * + * @param string $kind The kind to use in the final path element. + * @param array $options { + * Configuration Options + * + * @type array[] $ancestors An array of + * [PathElement](https://cloud.google.com/datastore/reference/rest/v1/Key#PathElement) arrays. Use to + * create [ancestor paths](https://cloud.google.com/datastore/docs/concepts/entities#ancestor_paths). + * @type int $number The number of keys to generate. + * @type string|int $id The ID for the last pathElement. + * @type string $name The Name for the last pathElement. + * } + * @return Key[] + */ + public function keys($kind, array $options = []) + { + $options += [ + 'number' => 1, + 'ancestors' => [], + 'id' => null, + 'name' => null + ]; + + $path = []; + if (count($options['ancestors']) > 0) { + $path = $options['ancestors']; + } + + $path[] = array_filter([ + 'kind' => $kind, + 'id' => $options['id'], + 'name' => $options['name'] + ]); + + $key = new Key($this->projectId, [ + 'path' => $path, + 'namespaceId' => $this->namespaceId + ]); + + $keys = array_fill(0, $options['number'], $key); + + return $keys; + } + + /** + * Create an entity + * + * This method does not execute any service requests. + * + * Entities are created with a Datastore Key, or by specifying a Kind. Kinds + * are only allowed for insert operations. For any other case, you must + * specify a complete key. If a kind is given, an ID will be automatically + * allocated for the entity upon insert. Additionally, if your entity + * requires a complex key elementPath, you must create the key separately. + * + * In complex applications you may want to create your own entity types. + * Google Cloud PHP supports subclassing of {@see Google\Cloud\Datastore\Entity}. + * If the name of a subclass of Entity is given in the options array, an + * instance of the subclass will be returned instead of Entity. + * + * @see https://cloud.google.com/datastore/reference/rest/v1/Entity Entity + * + * @param Key|string $key The key used to identify the record, or a string $kind. + * @param array $entity The data to fill the entity with. + * @param array $options { + * Configuration Options + * + * @type string $className The name of a class extending {@see Google\Cloud\Datastore\Entity}. + * If provided, an instance of that class will be returned instead of Entity. + * If not set, {@see Google\Cloud\Datastore\Entity} will be used. + * @type array $excludeFromIndexes A list of entity keys to exclude from + * datastore indexes. + * } + * @return Entity + * @throws InvalidArgumentException + */ + public function entity($key, array $entity = [], array $options = []) + { + $options += [ + 'className' => null + ]; + + if (!is_string($key) && !($key instanceof Key)) { + throw new InvalidArgumentException( + '$key must be an instance of Key or a string' + ); + } + + if (is_string($key)) { + $key = $this->key($key); + } + + $className = $options['className']; + if (!is_null($className) && !is_subclass_of($className, Entity::class)) { + throw new InvalidArgumentException(sprintf( + 'Given classname %s is not a subclass of Entity', + $className + )); + } + + if (is_null($className)) { + $className = Entity::class; + } + + return new $className($key, $entity, $options); + } + + /** + * Allocate available IDs to a set of keys + * + * Keys MUST be in an incomplete state (i.e. including a kind but not an ID + * or name in their final pathElement). + * + * This method will execute a service request. + * + * @see https://cloud.google.com/datastore/reference/rest/v1/projects/allocateIds allocateIds + * + * @param Key[] $keys The incomplete keys. + * @param array $options Configuration options. + * @return Key[] + * @throws InvalidArgumentException + */ + public function allocateIds(array $keys, array $options = []) + { + // Validate the given keys. First check types, then state of each. + // The API will throw a 400 if the key is complete, but it's an easy + // check we can handle before going to the API to save a request. + // @todo replace with json schema + $this->validateBatch($keys, Key::class, function ($key) { + if ($key->state() !== Key::STATE_INCOMPLETE) { + throw new InvalidArgumentException(sprintf( + 'Given $key is in an invalid state. Can only allocate IDs for incomplete keys. ' . + 'Given path was %s', + (string) $key + )); + } + }); + + $res = $this->connection->allocateIds([ + 'projectId' => $this->projectId, + 'keys' => $keys + ] + $options); + + if (isset($res['keys'])) { + foreach ($res['keys'] as $index => $key) { + $end = end($key['path']); + $id = $end['id']; + $keys[$index]->setLastElementIdentifier($id); + } + } + + return $keys; + } + + /** + * Lookup records by key + * + * @codingStandardsIgnoreStart + * @param Key[] $key The identifiers to look up. + * @param array $options { + * Configuration Options + * + * @type string $readConsistency If not in a transaction, set to STRONG + * or EVENTUAL, depending on default value in DatastoreClient. + * See + * [ReadConsistency](https://cloud.google.com/datastore/reference/rest/v1/ReadOptions#ReadConsistency). + * @type string $transaction The transaction ID, if the query should be + * run in a transaction. + * @type string|array $className If a string, the name of the class to return results as. + * Must be a subclass of {@see Google\Cloud\Datastore\Entity}. + * If not set, {@see Google\Cloud\Datastore\Entity} will be used. + * If an array is given, it must be an associative array, where + * the key is a Kind and the value is the name of a subclass of + * {@see Google\Cloud\Datastore\Entity}. + * } + * @return array Returns an array with keys [`found`, `missing`, and `deferred`]. + * Members of `found` will be instance of + * {@see Google\Cloud\Datastore\Entity}. Members of `missing` and + * `deferred` will be instance of {@see Google\Cloud\Datastore\Key}. + * @throws InvalidArgumentException + * @codingStandardsIgnoreEnd + */ + public function lookup(array $keys, array $options = []) + { + $options += [ + 'className' => null + ]; + + $this->validateBatch($keys, Key::class, function ($key) { + if ($key->state() !== Key::STATE_COMPLETE) { + throw new InvalidArgumentException(sprintf( + 'Given $key is in an invalid state. Can only lookup records when given a complete key. ' . + 'Given path was %s', + (string) $key + )); + } + }); + + $res = $this->connection->lookup($options + [ + 'projectId' => $this->projectId, + 'readOptions' => $this->readOptions($options), + 'keys' => $keys + ]); + + $result = []; + if (isset($res['found'])) { + $result['found'] = $this->mapEntityResult( + $res['found'], + $options['className'] + ); + } + + if (isset($res['missing'])) { + $result['missing'] = []; + foreach ($res['missing'] as $missing) { + $key = $this->key( + $missing['entity']['key']['path'], + $missing['entity']['key']['partitionId'] + ); + + $result['missing'][] = $key; + } + } + + if (isset($res['deferred'])) { + $result['deferred'] = []; + foreach ($res['deferred'] as $deferred) { + $key = $this->key( + $deferred['path'], + $deferred['partitionId'] + ); + + $result['deferred'][] = $key; + } + } + + return $result; + } + + /** + * Run a query and return entities + * + * @param QueryInterface $query The query object. + * @param array $options { + * Configuration Options + * + * @type string $transaction The transaction ID, if the query should be + * run in a transaction. + * @type string $className The name of the class to return results as. + * Must be a subclass of {@see Google\Cloud\Datastore\Entity}. + * If not set, {@see Google\Cloud\Datastore\Entity} will be used. + * @type string $readConsistency If not in a transaction, set to STRONG + * or EVENTUAL, depending on default value in DatastoreClient. + * See + * [ReadConsistency](https://cloud.google.com/datastore/reference/rest/v1/ReadOptions#ReadConsistency). + * } + * @return \Generator + */ + public function runQuery(QueryInterface $query, array $options = []) + { + $options += [ + 'className' => null + ]; + + $moreResults = true; + do { + $request = $options + [ + 'projectId' => $this->projectId, + 'partitionId' => $this->partitionId($this->projectId, $this->namespaceId), + 'readOptions' => $this->readOptions($options), + $query->queryKey() => $query->queryObject() + ]; + + $res = $this->connection->runQuery($request); + + if (isset($res['batch']['entityResults']) && is_array($res['batch']['entityResults'])) { + $results = $this->mapEntityResult( + $res['batch']['entityResults'], + $options['className'] + ); + + foreach ($results as $result) { + yield $result; + } + + if ($query->canPaginate() && $res['batch']['moreResults'] !== 'NO_MORE_RESULTS') { + $query->start($res['batch']['endCursor']); + } else { + $moreResults = false; + } + } else { + $moreResults = false; + } + } while ($moreResults); + } + + /** + * Commit all mutations + * + * Calling this method will end the operation (and close the transaction, + * if one is specified). + * + * @param array $options { + * Configuration Options + * + * @type string $transaction The transaction ID, if the query should be + * run in a transaction. + * } + * @return array [Response Body](https://cloud.google.com/datastore/reference/rest/v1/projects/commit#response-body) + */ + public function commit(array $options = []) + { + $options += [ + 'transaction' => null + ]; + + $res = $this->connection->commit($options + [ + 'mode' => ($options['transaction']) ? 'TRANSACTIONAL' : 'NON_TRANSACTIONAL', + 'mutations' => $this->mutations, + 'projectId' => $this->projectId + ]); + + $this->mutations = []; + + return $res; + } + + /** + * Patch any incomplete keys in the given array of entities + * + * Any incomplete keys will be allocated an ID. Complete keys in the input + * will remain unchanged. + * + * @param Entity[] $entities A list of entities + * @return Entity[] + */ + public function allocateIdsToEntities(array $entities) + { + $this->validateBatch($entities, Entity::class); + + $incompleteKeys = []; + foreach ($entities as $entity) { + if ($entity->key()->state() === Key::STATE_INCOMPLETE) { + $incompleteKeys[] = $entity->key(); + } + } + + $this->allocateIds($incompleteKeys); + + return $entities; + } + + /** + * Enqueue a mutation + * + * A mutation is a change to the datastore. Create, Update and Delete are + * examples of mutations, while Read is not. + * + * Google Cloud Datastore supports multiple mutations in a single API call, + * subject to the limits of the service. Adding mutations separately from + * committing the changes allows you to create complex operations, both + * inside a transaction and not. + * + * @see https://cloud.google.com/datastore/docs/concepts/limits Limits + * + * @param string $operation The operation to execute. "Insert", "Upsert", + * "Update" or "Delete". + * @param Entity[]|Key[] $input The entities or keys to mutate. + * @param string $type The type of the input array. + * @param string $baseVersion The version of the entity that this mutation + * is being applied to. If this does not match the current version on + * the server, the mutation conflicts. + * @return void + * @throws InvalidArgumentException + */ + public function mutate( + $operation, + array $input, + $type, + $baseVersion = null + ) { + $this->validateBatch($input, $type); + + foreach ($input as $element) { + // If the given element is an Entity, it will use that baseVersion. + if ($element instanceof Entity) { + $baseVersion = $element->baseVersion(); + $data = $this->entityMapper->objectToRequest($element); + } elseif ($element instanceof Key) { + $data = $element->keyObject(); + } else { + throw new InvalidArgumentException(sprintf( + 'Element must be a Key or Entity, %s given', + get_class($element) + )); + } + + $this->mutations[] = array_filter([ + $operation => $data, + 'baseVersion' => $baseVersion + ]); + } + } + + /** + * Check whether an update or upsert operation may proceed safely + * + * @param Entity[] $entities the entities to be updated or upserted. + * @param bool $allowOverwrite `false` by default. If `true`, safety will + * be disregarded. + * @throws InvalidArgumentException + * @return void + */ + public function checkOverwrite(array $entities, $allowOverwrite = false) + { + $this->validateBatch($entities, Entity::class); + + foreach ($entities as $entity) { + if (!$entity->populatedByService() && !$allowOverwrite) { + throw new InvalidArgumentException(sprintf( + 'Given entity cannot be saved because it may overwrite an '. + 'existing record. When manually creating entities for '. + 'update operations, please set the options '. + '`$allowOverwrite` flag to `true`. Invalid entity key was %s', + (string) $entity->key() + )); + } + } + } + + /** + * Convert an EntityResult into an array of entities + * + * @see https://cloud.google.com/datastore/reference/rest/v1/EntityResult EntityResult + * + * @param array $entityResult The EntityResult from a Lookup. + * @param string|array $class If a string, the name of the class to return results as. + * Must be a subclass of {@see Google\Cloud\Datastore\Entity}. + * If not set, {@see Google\Cloud\Datastore\Entity} will be used. + * If an array is given, it must be an associative array, where + * the key is a Kind and the value is the name of a subclass of + * {@see Google\Cloud\Datastore\Entity}. + * @return Entity[] + */ + private function mapEntityResult(array $entityResult, $class) + { + $entities = []; + + foreach ($entityResult as $result) { + $entity = $result['entity']; + + $properties = $this->entityMapper->responseToProperties($entity['properties']); + $excludes = $this->entityMapper->responseToExcludeFromIndexes($entity['properties']); + + $namespaceId = (isset($entity['key']['partitionId']['namespaceId'])) + ? $entity['key']['partitionId']['namespaceId'] + : null; + + $key = new Key($this->projectId, [ + 'path' => $entity['key']['path'], + 'namespaceId' => $namespaceId + ]); + + if (is_array($class)) { + $lastPathElement = $key->pathEnd(); + if (!array_key_exists($lastPathElement['kind'], $class)) { + throw new InvalidArgumentException(sprintf( + 'No class found for kind %s', + $lastPathElement['kind'] + )); + } + + $className = $class[$lastPathElement['kind']]; + } else { + $className = $class; + } + + $entities[] = $this->entity($key, $properties, [ + 'cursor' => (isset($result['cursor'])) + ? $result['cursor'] + : null, + 'baseVersion' => (isset($result['version'])) + ? $result['version'] + : null, + 'className' => $className, + 'populatedByService' => true, + 'excludeFromIndexes' => $excludes + ]); + } + + return $entities; + } + + /** + * Format the readOptions + * + * @param array $options { + * Read Options + * + * @type string $transaction If set, query or lookup will run in transaction. + * @type string $readConsistency If not in a transaction, set to STRONG + * or EVENTUAL, depending on default value in DatastoreClient. + * } + * @return array + */ + private function readOptions(array $options = []) + { + $options += [ + 'readConsistency' => DatastoreClient::DEFAULT_READ_CONSISTENCY, + 'transaction' => null + ]; + + if ($options['transaction']) { + return [ + 'transaction' => $options['transaction'] + ]; + } + + return [ + 'readConsistency' => $options['readConsistency'] + ]; + } + + /** + * Check that each member of $input array is of type $type. + * + * @param array $input The input to validate. + * @param string $type The type to check.. + * @param callable An additional check for each element of $input. + * This will be run count($input) times, so use with care. + * @return void + * @throws InvalidArgumentException + */ + private function validateBatch( + array $input, + $type, + callable $additionalCheck = null + ) { + foreach ($input as $element) { + if (!($element instanceof $type)) { + throw new InvalidArgumentException(sprintf( + 'Each member of input array must be an instance of %s', + $type + )); + } + + if ($additionalCheck) { + $additionalCheck($element); + } + } + } +} diff --git a/src/Datastore/Query/GqlQuery.php b/src/Datastore/Query/GqlQuery.php new file mode 100644 index 000000000000..5d63843d5fa7 --- /dev/null +++ b/src/Datastore/Query/GqlQuery.php @@ -0,0 +1,242 @@ +datastore(); + * + * $query = $datastore->gqlQuery('SELECT * FROM Companies WHERE companyName = @companyName', [ + * 'bindings' => [ + * 'companyName' => 'Google' + * ] + * ]); + * + * $res = $datastore->runQuery($query); + * foreach ($res as $company) { + * echo $company['companyName']; // Google + * } + * ``` + * + * ``` + * // Positional binding is also supported + * $query = $datastore->gqlQuery('SELECT * FROM Companies WHERE companyName = @1', [ + * 'bindings' => [ + * 'Google' + * ] + * ]); + * ``` + * + * ``` + * // While not recommended, you can use literals in your query string: + * $query = $datastore->gqlQuery("SELECT * FROM Companies WHERE companyName = 'Google'", [ + * 'allowLiterals' => true + * ]); + * ``` + * + * @see https://cloud.google.com/datastore/docs/apis/gql/gql_reference GQL Reference + */ +class GqlQuery implements QueryInterface +{ + use DatastoreTrait; + + const BINDING_NAMED = 'namedBindings'; + const BINDING_POSITIONAL = 'positionalBindings'; + + /** + * @var EntityMapper + */ + private $entityMapper; + + /** + * @var string + */ + private $query; + + /** + * @var array + */ + private $options; + + /** + * @var array + */ + private $allowedBindingTypes = [ + self::BINDING_NAMED, + self::BINDING_POSITIONAL, + ]; + + /** + * @param EntityMapper $entityMapper An instance of EntityMapper + * @param string $query The GQL Query string. + * @param array $options { + * Configuration Options + * + * @type bool $allowLiterals Whether literal values will be allowed in + * the query string. This is false by default, and parameter + * binding is strongly encouraged over literals. + * @type array $bindings An array of values to bind to the query string. + * Queries using Named Bindings should provide a key/value set, + * while queries using Positional Bindings must provide a simple + * array. + * Applications with no need for multitenancy should not set this value. + * } + */ + public function __construct(EntityMapper $entityMapper, $query, array $options = []) + { + $this->entityMapper = $entityMapper; + $this->query = $query; + $this->options = $options + [ + 'allowLiterals' => false, + 'bindingType' => $this->determineBindingType($options), + 'bindings' => [] + ]; + } + + /** + * Format the query for use in the API + * + * This method is used internally to run queries and is not intended for use + * outside the internal library API + * + * @access private + * @return array + */ + public function queryObject() + { + $bindingType = $this->options['bindingType']; + + $queryObj = []; + $queryObj['queryString'] = $this->query; + $queryObj['allowLiterals'] = (bool) $this->options['allowLiterals']; + + $bindings = $this->mapBindings($bindingType, $this->options['bindings']); + if (!empty($bindings)) { + $queryObj[$this->options['bindingType']] = $bindings; + } + + return $queryObj; + } + + /** + * Return the query_type union field name. + * + * @return string + * @access private + */ + public function queryKey() + { + return "gqlQuery"; + } + + /** + * Indicate that this type does not support automatic pagination. + * + * @access private + * @return bool + */ + public function canPaginate() + { + return false; + } + + /** + * Fulfill the interface, but cursors are handled inside the query string. + * + * @param string $cursor + * @return void + * @access private + * @codeCoverageIgnore + */ + public function start($cursor) + //@codingStandardsIgnoreStart + {} + //@codingStandardsIgnoreEnd + + /** + * Define the json respresentation of the object. + * + * @access private + * @return array + */ + public function jsonSerialize() + { + return $this->queryObject(); + } + + /** + * Format bound values for the API + * + * @param string $bindingType Either named or positional bindings. + * @param array $bindings The bindings to map + * @return array + */ + private function mapBindings($bindingType, array $bindings) + { + $res = []; + foreach ($bindings as $key => $binding) { + $value = $this->entityMapper->valueObject($binding); + + if ($bindingType === self::BINDING_NAMED) { + $res[$key] = [ + 'value' => $value + ]; + } else { + $res[] = [ + 'value' => $value + ]; + } + } + + return $res; + } + + /** + * Determine whether the query should use named or positional bindings. + * + * @param array $options + * @return string + */ + private function determineBindingType(array $options) + { + if (isset($options['bindings']) && !$this->isAssoc($options['bindings'])) { + return self::BINDING_POSITIONAL; + } + + return self::BINDING_NAMED; + } +} diff --git a/src/Datastore/Query/Query.php b/src/Datastore/Query/Query.php new file mode 100644 index 000000000000..48dd51354908 --- /dev/null +++ b/src/Datastore/Query/Query.php @@ -0,0 +1,481 @@ +datastore(); + * + * $query = $datastore->query(); + * $query->kind('Person'); + * $query->filter('firstName', 'Bob'); + * + * $result = $datastore->runQuery($query); + * ``` + * + * ``` + * // Queries can also be constructed using a Query Object: + * $query = $datastore->query([ + * 'query' => [ + * 'kind' => [ + * [ + * 'name' => 'People' + * ] + * ], + * 'filter' => [ + * 'propertyFilter' => [ + * 'op' => 'EQUAL', + * 'property' => [ + * 'name' => 'firstName' + * ], + * 'value' => [ + * 'stringValue': 'Bob' + * ] + * ] + * ] + * ] + * ]); + * + * $result = $datastore->runQuery($query); + * ``` + * + * @see https://cloud.google.com/datastore/reference/rest/v1/projects/runQuery#query Query Object Reference + * @see https://cloud.google.com/datastore/docs/concepts/queries Datastore Queries + */ +class Query implements QueryInterface +{ + use DatastoreTrait; + + const OP_DEFAULT = self::OP_EQUALS; + const OP_LESS_THAN = 'LESS_THAN'; + const OP_LESS_THAN_OR_EQUAL = 'LESS_THAN_OR_EQUAL'; + const OP_GREATER_THAN = 'GREATER_THAN'; + const OP_GREATER_THAN_OR_EQUAL = 'GREATER_THAN_OR_EQUAL'; + const OP_EQUALS = 'EQUAL'; + const OP_HAS_ANCESTOR = 'HAS_ANCESTOR'; + + const ORDER_DEFAULT = self::ORDER_ASCENDING; + const ORDER_DESCENDING = 'ASCENDING'; + const ORDER_ASCENDING = 'DESCENDING'; + + /** + * @var array A list of all operators supported by datastore + */ + private $allowedOperators = [ + self::OP_LESS_THAN, + self::OP_LESS_THAN_OR_EQUAL, + self::OP_GREATER_THAN, + self::OP_GREATER_THAN_OR_EQUAL, + self::OP_EQUALS, + self::OP_HAS_ANCESTOR, + ]; + + /** + * @var array A list of comparison operators that map to datastore operators + */ + private $shortOperators = [ + '<' => self::OP_LESS_THAN, + '<=' => self::OP_LESS_THAN_OR_EQUAL, + '>' => self::OP_GREATER_THAN, + '>=' => self::OP_GREATER_THAN_OR_EQUAL, + '=' => self::OP_EQUALS + ]; + + /** + * @var array A list of available ordering directions + */ + private $allowedOrders = [ + self::ORDER_ASCENDING, + self::ORDER_DESCENDING + ]; + + /** + * @var EntityMapper + */ + private $entityMapper; + + /** + * @var array + */ + private $options; + + /** + * @param EntityMapper $entityMapper An instance of EntityMapper + * @param array $options { + * Configuration Options + * + * @type array $query [Query](https://cloud.google.com/datastore/reference/rest/v1/projects/runQuery#query) + * } + */ + public function __construct(EntityMapper $entityMapper, array $options = []) + { + $this->entityMapper = $entityMapper; + $this->options = $options + [ + 'query' => [ + 'projection' => [], + 'kind' => [], + 'order' => [], + 'distinctOn' => [] + ] + ]; + } + + /** + * Set the Query Projection. + * + * Accepts an array of properties. If set, only these properties will be + * returned. + * + * Example: + * ``` + * $query->projection(['firstName', 'lastName']); + * ``` + * + * @param array|string $properties The property or properties to include in + * the result. + * @return Query + */ + public function projection($properties) + { + if (!is_array($properties)) { + $properties = [$properties]; + } + + $this->options['query']['projection'] = $properties; + + return $this; + } + + /** + * Set the Kind to query. + * + * If empty, returns entities of all kinds. Must be set in order to filter + * results. While you may supply as many kinds as you wish, datastore currently + * only accepts one at a time. + * + * Example: + * ``` + * $query->kind('Person'); + * ``` + * + * @param array|string $kinds The kind or kinds to return. Only a single kind + * is currently supported. + * @return Query + */ + public function kind($kinds) + { + if (!is_array($kinds)) { + $kinds = [$kinds]; + } + + foreach ($kinds as $kind) { + $this->options['query']['kind'][] = $this->propertyName($kind); + } + + return $this; + } + + /** + * Add a filter to the query. + * + * If the top-level filter is specified as a propertyFilter, it will be replaced. + * Any composite filters will be preserved and the new filter will be added. + * + * Example: + * ``` + * $query->filter('firstName', '=', 'Bob') + * ->filter('lastName', '=', 'Testguy'); + * ``` + * + * @see https://cloud.google.com/datastore/reference/rest/v1/projects/runQuery#operator_1 Allowed Operators + * + * @param string $property The property to filter. + * @param string $operator The operator to use in the filter. A list of + * allowed operators may be found + * [here](https://cloud.google.com/datastore/reference/rest/v1/projects/runQuery#operator_1). + * Short comparison operators are provided for convenience and are + * mapped to their datastore-compatible equivalents. Available short + * operators are `=`, `<`, `<=`, `>`, and `>=`. + * @param mixed $value The value to check. + * @return Query + */ + public function filter($property, $operator, $value) + { + if (!isset($this->options['query']['filter']) || !isset($this->options['query']['filter']['compositeFilter'])) { + $this->initializeFilter(); + } + + $this->options['query']['filter']['compositeFilter']['filters'][] = [ + 'propertyFilter' => [ + 'property' => $this->propertyName($property), + 'value' => $this->entityMapper->valueObject($value), + 'op' => $this->mapOperator($operator) + ] + ]; + + return $this; + } + + /** + * Specify an order for the query + * + * Example: + * ``` + * $query->order('birthDate'); + * ``` + * + * @see https://cloud.google.com/datastore/reference/rest/v1/projects/runQuery#Direction Allowed Directions + * + * @param string $property The property to order by. + * @param string $direction The direction to order in. + * @return Query + */ + public function order($property, $direction = self::ORDER_DEFAULT) + { + $this->options['query']['order'][] = [ + 'property' => $this->propertyName($property), + 'direction' => $direction + ]; + + return $this; + } + + /** + * The properties to make distinct. + * + * The query results will contain the first result for each distinct + * combination of values for the given properties (if empty, all results + * are returned). + * + * Example: + * ``` + * $query->distinctOn('lastName'); + * ``` + * + * @param array|string $property The property or properties to make distinct. + * @return Query + */ + public function distinctOn($property) + { + if (!is_array($property)) { + $property = [$property]; + } + + foreach ($property as $prop) { + $this->options['query']['distinctOn'][] = $this->propertyName($prop); + } + + return $this; + } + + /** + * The starting point for the query results + * + * Example: + * ``` + * $query->start($lastResultCursor); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/datastore/docs/concepts/queries#cursors_limits_and_offsets Cursors, Limits and Offsets + * @codingStandardsIgnoreEnd + * + * @param string $cursor The cursor on which to start the result. + * @return Query + */ + public function start($cursor) + { + $this->options['query']['startCursor'] = $cursor; + + return $this; + } + + /** + * The ending point for the query results + * + * Example: + * ``` + * $query->end($lastResultCursor); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/datastore/docs/concepts/queries#cursors_limits_and_offsets Cursors, Limits and Offsets + * @codingStandardsIgnoreEnd + * + * @param string $cursor The cursor on which to end the result. + * @return Query + */ + public function end($cursor) + { + $this->options['query']['endCursor'] = $cursor; + + return $this; + } + + /** + * The number of results to skip + * + * Example: + * ``` + * $query->offset(2); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/datastore/docs/concepts/queries#cursors_limits_and_offsets Cursors, Limits and Offsets + * @codingStandardsIgnoreEnd + * + * @param int $num The number of results to skip. + * @return Query + */ + public function offset($num) + { + $this->options['query']['offset'] = $num; + + return $this; + } + + /** + * The number of results to return + * + * Example: + * ``` + * $query->limit(50); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/datastore/docs/concepts/queries#cursors_limits_and_offsets Cursors, Limits and Offsets + * @codingStandardsIgnoreEnd + * + * @param int $num The number of results to return. + * @return Query + */ + public function limit($num) + { + $this->options['query']['limit'] = $num; + + return $this; + } + + /** + * Indicate that this type does support automatic pagination. + * + * @access private + * @return bool + */ + public function canPaginate() + { + return true; + } + + /** + * Return a service-compliant array. + * + * This method is intended for use internally by the PHP client. + * + * @access private + * @return array + */ + public function queryObject() + { + return array_filter($this->options['query']); + } + + /** + * Return the query_type union field name. + * + * @return string + * @access private + */ + public function queryKey() + { + return "query"; + } + + /** + * @access private + */ + public function jsonSerialize() + { + return $this->queryObject(); + } + + /** + * Setup the filter object when the first filter is created + * + * @return void + */ + private function initializeFilter() + { + $this->options['query']['filter'] = [ + 'compositeFilter' => [ + 'filters' => [], + 'op' => 'AND' + ] + ]; + } + + /** + * Format a property name + * + * @param string $property The property name. + * @return array + */ + private function propertyName($property) + { + return [ + 'name' => $property + ]; + } + + /** + * Convert given operator to API-compatible operator + * + * @param string $operator + * @return string + */ + private function mapOperator($operator) + { + if (array_key_exists($operator, $this->shortOperators)) { + $operator = $this->shortOperators[$operator]; + } + + if (!in_array($operator, $this->allowedOperators)) { + throw new InvalidArgumentException(sprintf( + 'Invalid operator `%s` given. Valid operators are %s.', + $operator, + implode(', ', $this->allowedOperators) + )); + } + + return $operator; + } +} diff --git a/src/Datastore/Query/QueryInterface.php b/src/Datastore/Query/QueryInterface.php new file mode 100644 index 000000000000..a620c6c7f690 --- /dev/null +++ b/src/Datastore/Query/QueryInterface.php @@ -0,0 +1,59 @@ +datastore(); + * + * $transaction = $datastore->transaction(); + * ``` + * + * @see https://cloud.google.com/datastore/docs/concepts/transactions Transactions + */ +class Transaction +{ + /** + * @var Operation + */ + private $operation; + + /** + * @var string + */ + private $projectId; + + /** + * @var string + */ + private $transactionId; + + /** + * @var array + */ + private $mutations = []; + + /** + * Create a Transaction + * + * @param Operation $operation Class that handles shared API interaction. + * @param string $projectId The Google Cloud Platform project ID. + * @param string $transactionId The transaction to run mutations in. + */ + public function __construct( + Operation $operation, + $projectId, + $transactionId + ) { + $this->operation = $operation; + $this->projectId = $projectId; + $this->transactionId = $transactionId; + } + + /** + * Insert an entity + * + * No service requests are run when this method is called. + * Use {@see Google\Cloud\Datastore\Transaction::commit()} to commit changes. + * + * Example: + * ``` + * $key = $datastore->key('Person', 'Bob'); + * $entity = $datastore->entity($key, ['firstName' => 'Bob']); + * + * $transaction->insert($entity); + * $transaction->commit(); + * ``` + * + * @param Entity $entity The entity to insert. + * @return Transaction + */ + public function insert(Entity $entity) + { + return $this->insertBatch([$entity]); + } + + /** + * Insert multiple entities + * + * No service requests are run when this method is called. + * Use {@see Google\Cloud\Datastore\Transaction::commit()} to commit changes. + * + * Example: + * ``` + * $entities = [ + * $datastore->entity('Person', ['firstName' => 'Bob']), + * $datastore->entity('Person', ['firstName' => 'John']) + * ]; + * + * $transaction->insertBatch($entities); + * $transaction->commit(); + * ``` + * + * @param Entity[] $entities The entities to insert. + * @return Transaction + */ + public function insertBatch(array $entities) + { + $entities = $this->operation->allocateIdsToEntities($entities); + $this->operation->mutate('insert', $entities, Entity::class); + + return $this; + } + + /** + * Update an entity + * + * No service requests are run when this method is called. + * Use {@see Google\Cloud\Datastore\Transaction::commit()} to commit changes. + * + * Example: + * ``` + * $entity['firstName'] = 'Bob'; + * + * $transaction->update($entity); + * $transaction->commit(); + * ``` + * + * @param Entity $entity The entity to update. + * @param array $options { + * Configuration Options + * + * @type bool $allowOverwrite Set to `false` by default. Entities must + * be updated as an entire resource. Patch operations are not + * supported. Because entities can be created manually, or + * obtained by a lookup or query, it is possible to accidentally + * overwrite an existing record with a new one when manually + * creating an entity. To provide additional safety, this flag + * must be set to `true` in order to update a record when the + * entity provided was not obtained through a lookup or query. + * } + * @return Transaction + */ + public function update(Entity $entity, array $options = []) + { + $options += [ + 'allowOverwrite' => false + ]; + + return $this->updateBatch([$entity], $options); + } + + /** + * Update multiple entities + * + * No service requests are run when this method is called. + * Use {@see Google\Cloud\Datastore\Transaction::commit()} to commit changes. + * + * Example: + * ``` + * $entities[0]['firstName'] = 'Bob'; + * $entities[1]['firstName'] = 'John'; + * + * $transaction->updateBatch($entities); + * $transaction->commit(); + * ``` + * + * @param Entity[] $entities The entities to update. + * @param array $options { + * Configuration Options + * + * @type bool $allowOverwrite Set to `false` by default. Entities must + * be updated as an entire resource. Patch operations are not + * supported. Because entities can be created manually, or + * obtained by a lookup or query, it is possible to accidentally + * overwrite an existing record with a new one when manually + * creating an entity. To provide additional safety, this flag + * must be set to `true` in order to update a record when the + * entity provided was not obtained through a lookup or query. + * } + * @return Transaction + */ + public function updateBatch(array $entities, array $options = []) + { + $options += [ + 'allowOverwrite' => false + ]; + + $this->operation->checkOverwrite($entities, $options['allowOverwrite']); + $this->operation->mutate('update', $entities, Entity::class); + + return $this; + } + + /** + * Upsert an entity + * + * No service requests are run when this method is called. + * Use {@see Google\Cloud\Datastore\Transaction::commit()} to commit changes. + * + * Upsert will create a record if one does not already exist, or overwrite + * existing record if one already exists. + * + * Example: + * ``` + * $key = $datastore->key('Person', 'Bob'); + * $entity = $datastore->entity($key, ['firstName' => 'Bob']); + * + * $transaction->upsert($entity); + * $transaction->commit(); + * ``` + * + * @param Entity $entity The entity to upsert. + * @return Transaction + */ + public function upsert(Entity $entity) + { + return $this->upsertBatch([$entity]); + } + + /** + * Upsert multiple entities + * + * No service requests are run when this method is called. + * Use {@see Google\Cloud\Datastore\Transaction::commit()} to commit changes. + * + * Upsert will create a record if one does not already exist, or overwrite + * existing record if one already exists. + * + * Example: + * ``` + * $keys = [ + * $datastore->key('Person', 'Bob'), + * $datastore->key('Person', 'John') + * ]; + * + * $entities = [ + * $datastore->entity($key[0], ['firstName' => 'Bob']), + * $datastore->entity($key[1], ['firstName' => 'John']) + * ]; + * + * $transaction->upsertBatch($entities); + * $transaction->commit(); + * ``` + * + * @param Entity[] $entities The entities to upsert. + * @return Transaction + */ + public function upsertBatch(array $entities) + { + $this->operation->mutate('upsert', $entities, Entity::class); + + return $this; + } + + /** + * Delete a record + * + * No service requests are run when this method is called. + * Use {@see Google\Cloud\Datastore\Transaction::commit()} to commit changes. + * + * Example: + * ``` + * $key = $datastore->key('Person', 'Bob'); + * + * $transaction->delete($key); + * $transaction->commit(); + * ``` + * + * @param Key $key The key to delete + * @return Transaction + */ + public function delete(Key $key) + { + return $this->deleteBatch([$key]); + } + + /** + * Delete multiple records + * + * No service requests are run when this method is called. + * Use {@see Google\Cloud\Datastore\Transaction::commit()} to commit changes. + * + * Example: + * ``` + * $keys = [ + * $datastore->key('Person', 'Bob'), + * $datastore->key('Person', 'John') + * ]; + * + * $transaction->deleteBatch($keys); + * $transaction->commit(); + * ``` + * + * @param Key[] $keys The keys to delete. + * @return Transaction + */ + public function deleteBatch(array $keys) + { + $this->operation->mutate('delete', $keys, Key::class); + + return $this; + } + + /** + * Retrieve an entity from the datastore inside a transaction + * + * Example: + * ``` + * $key = $datastore->key('Person', 'Bob'); + * + * $entity = $transaction->lookup($key); + * if (!is_null($entity)) { + * echo $entity['firstName']; // 'Bob' + * } + * ``` + * + * @param Key $key $key The identifier to use to locate a desired entity. + * @param array $options { + * Configuration Options + * + * @type string $className The name of the class to return results as. + * Must be a subclass of {@see Google\Cloud\Datastore\Entity}. + * If not set, {@see Google\Cloud\Datastore\Entity} will be used. + * } + * @return Entity|null + */ + public function lookup(Key $key, array $options = []) + { + $res = $this->lookupBatch([$key], $options); + + return (isset($res['found'][0])) + ? $res['found'][0] + : null; + } + + /** + * Get multiple entities inside a transaction + * + * Example: + * ``` + * $keys = [ + * $datastore->key('Person', 'Bob'), + * $datastore->key('Person', 'John') + * ]; + * + * $entities = $transaction->lookup($keys); + * + * foreach ($entities['found'] as $entity) { + * echo $entity['firstName']; + * } + * ``` + * + * @param Key[] $key The identifiers to look up. + * @param array $options { + * Configuration Options + * + * @type string|array $className If a string, the name of the class to return results as. + * Must be a subclass of {@see Google\Cloud\Datastore\Entity}. + * If not set, {@see Google\Cloud\Datastore\Entity} will be used. + * If an array is given, it must be an associative array, where + * the key is a Kind and the value is the name of a subclass of + * {@see Google\Cloud\Datastore\Entity}. + * } + * @return array Returns an array with keys [`found`, `missing`, and `deferred`]. + * Members of `found` will be instance of + * {@see Google\Cloud\Datastore\Entity}. Members of `missing` and + * `deferred` will be instance of {@see Google\Cloud\Datastore\Key}. + */ + public function lookupBatch(array $keys, array $options = []) + { + return $this->operation->lookup($keys, $options + [ + 'transaction' => $this->transactionId + ]); + } + + /** + * Run a query and return entities inside a Transaction + * + * Example: + * ``` + * $result = $transaction->runQuery($query); + * + * foreach ($result as $entity) { + * echo $entity['firstName']; + * } + * ``` + * + * @param QueryInterface $query The query object. + * @param array $options { + * Configuration Options + * + * @type string $className The name of the class to return results as. + * Must be a subclass of {@see Google\Cloud\Datastore\Entity}. + * If not set, {@see Google\Cloud\Datastore\Entity} will be used. + * } + * @return \Generator + */ + public function runQuery(QueryInterface $query, array $options = []) + { + return $this->operation->runQuery($query, $options + [ + 'transaction' => $this->transactionId + ]); + } + + /** + * Commit all mutations + * + * Calling this method will end the operation (and close the transaction, + * if one is specified). + * + * Example: + * ``` + * $transaction->commit() + * ``` + * + * @param array $options Configuration Options. + * @return array [Response Body](https://cloud.google.com/datastore/reference/rest/v1/projects/commit#response-body) + */ + public function commit(array $options = []) + { + $options['transaction'] = $this->transactionId; + + return $this->operation->commit($options); + } +} diff --git a/src/PubSub/Subscription.php b/src/PubSub/Subscription.php index 704c00528dd5..d90c4c9086f0 100644 --- a/src/PubSub/Subscription.php +++ b/src/PubSub/Subscription.php @@ -347,9 +347,9 @@ public function reload(array $options = []) * wait until new messages are available. * @type int $maxMessages Limit the amount of messages pulled. * } - * @codeStandardsIgnoreStart + * @codingStandardsIgnoreStart * @return \Generator [ReceivedMessage](https://cloud.google.com/pubsub/reference/rest/v1/projects.subscriptions/pull#ReceivedMessage) - * @codeStandardsIgnoreEnd + * @codingStandardsIgnoreEnd */ public function pull(array $options = []) { diff --git a/src/ServiceBuilder.php b/src/ServiceBuilder.php index a0b17a4a7c8c..d45deb9a6584 100644 --- a/src/ServiceBuilder.php +++ b/src/ServiceBuilder.php @@ -19,9 +19,10 @@ use Google\Auth\HttpHandler\HttpHandlerFactory; use Google\Cloud\BigQuery\BigQueryClient; +use Google\Cloud\Datastore\DatastoreClient; use Google\Cloud\Logging\LoggingClient; -use Google\Cloud\PubSub\PubSubClient; use Google\Cloud\NaturalLanguage\NaturalLanguageClient; +use Google\Cloud\PubSub\PubSubClient; use Google\Cloud\Storage\StorageClient; use Google\Cloud\Translate\TranslateClient; use Google\Cloud\Vision\VisionClient; @@ -60,7 +61,7 @@ class ServiceBuilder * ``` * use Google\Cloud\ServiceBuilder; * - * $builder = new ServiceBuilder([ + * $cloud = new ServiceBuilder([ * 'projectId' => 'myAwesomeProject' * ]); * ``` @@ -90,156 +91,139 @@ public function __construct(array $config = []) } /** - * Google Cloud Storage client. Allows you to store and retrieve data on - * Google's infrastructure. Find more information at - * [Google Cloud Storage API docs](https://developers.google.com/storage). + * Google Cloud BigQuery client. Allows you to create, manage, share and query + * data. Find more information at + * [Google Cloud BigQuery Docs](https://cloud.google.com/bigquery/what-is-bigquery). * * Example: * ``` - * use Google\Cloud\ServiceBuilder; - * - * $builder = new ServiceBuilder([ - * 'projectId' => 'myAwesomeProject' - * ]); - * - * $storage = $builder->storage(); + * $bigQuery = $cloud->bigQuery(); * ``` * * @param array $config Configuration options. See * {@see Google\Cloud\ServiceBuilder::__construct()} for the available options. - * @return StorageClient + * @return BigQueryClient */ - public function storage(array $config = []) + public function bigQuery(array $config = []) { - return new StorageClient($config ? $this->resolveConfig($config) : $this->config); + return new BigQueryClient($config ? $this->resolveConfig($config) : $this->config); } /** - * Google Cloud BigQuery client. Allows you to create, manage, share and query - * data. Find more information at - * [Google Cloud BigQuery Docs](https://cloud.google.com/bigquery/what-is-bigquery). + * Google Cloud Datastore client. Cloud Datastore is a highly-scalable NoSQL + * database for your applications. Find more information at + * [Google Cloud Datastore docs](https://cloud.google.com/datastore/docs/). * * Example: * ``` - * use Google\Cloud\ServiceBuilder; - * - * $builder = new ServiceBuilder([ - * 'projectId' => 'myAwesomeProject' - * ]); - * - * $bigQuery = $builder->bigQuery(); + * $datastore = $cloud->datastore(); * ``` * * @param array $config Configuration options. See * {@see Google\Cloud\ServiceBuilder::__construct()} for the available options. - * @return BigQueryClient + * @return DatastoreClient */ - public function bigQuery(array $config = []) + public function datastore(array $config = []) { - return new BigQueryClient($config ? $this->resolveConfig($config) : $this->config); + return new DatastoreClient($config ? $this->resolveConfig($config) : $this->config); } /** - * Google Cloud Pub/Sub client. Allows you to send and receive - * messages between independent applications. Find more information at - * [Google Cloud Pub/Sub docs](https://cloud.google.com/pubsub/docs/). + * Google Stackdriver Logging client. Allows you to store, search, analyze, + * monitor, and alert on log data and events from Google Cloud Platform and + * Amazon Web Services. Find more information at + * [Google Stackdriver Logging docs](https://cloud.google.com/logging/docs/). * * Example: * ``` - * use Google\Cloud\ServiceBuilder; - * - * $builder = new ServiceBuilder([ - * 'projectId' => 'myAwesomeProject' - * ]); - * - * $pubsub = $builder->pubsub(); + * $logging = $cloud->logging(); * ``` * * @param array $config Configuration options. See * {@see Google\Cloud\ServiceBuilder::__construct()} for the available options. - * @return PubSubClient + * @return LoggingClient */ - public function pubsub(array $config = []) + public function logging(array $config = []) { - return new PubSubClient($config ? $this->resolveConfig($config) : $this->config); + return new LoggingClient($config ? $this->resolveConfig($config) : $this->config); } /** - * Google Cloud Vision client. Allows you to understand the content of an - * image, classify images into categories, detect text, objects, faces and - * more. Find more information at [Google Cloud Vision docs](https://cloud.google.com/vision/docs/). + * Google Cloud Natural Language client. Provides natural language + * understanding technologies to developers, including sentiment analysis, + * entity recognition, and syntax analysis. Currently only English, Spanish, + * and Japanese textual context are supported. Find more information at + * [Google Cloud Natural Language docs](https://cloud.google.com/natural-language/docs/). * * Example: * ``` - * use Google\Cloud\ServiceBuilder; - * - * $builder = new ServiceBuilder([ - * 'projectId' => 'myAwesomeProject' - * ]); - * - * $vision = $builder->vision(); + * $language = $cloud->naturalLanguage(); * ``` * * @param array $config Configuration options. See * {@see Google\Cloud\ServiceBuilder::__construct()} for the available options. - * @return VisionClient + * @return NaturalLanguageClient */ - public function vision(array $config = []) + public function naturalLanguage(array $config = []) { - return new VisionClient($config ? $this->resolveConfig($config) : $this->config); + return new NaturalLanguageClient($config ? $this->resolveConfig($config) : $this->config); } /** - * Google Stackdriver Logging client. Allows you to store, search, analyze, - * monitor, and alert on log data and events from Google Cloud Platform and - * Amazon Web Services. Find more information at - * [Google Stackdriver Logging docs](https://cloud.google.com/logging/docs/). + * Google Cloud Pub/Sub client. Allows you to send and receive + * messages between independent applications. Find more information at + * [Google Cloud Pub/Sub docs](https://cloud.google.com/pubsub/docs/). * * Example: * ``` - * use Google\Cloud\ServiceBuilder; - * - * $builder = new ServiceBuilder([ - * 'projectId' => 'myAwesomeProject' - * ]); - * - * $logging = $builder->logging(); + * $pubsub = $cloud->pubsub(); * ``` * * @param array $config Configuration options. See * {@see Google\Cloud\ServiceBuilder::__construct()} for the available options. - * @return LoggingClient + * @return PubSubClient */ - public function logging(array $config = []) + public function pubsub(array $config = []) { - return new LoggingClient($config ? $this->resolveConfig($config) : $this->config); + return new PubSubClient($config ? $this->resolveConfig($config) : $this->config); } /** - * Google Cloud Natural Language client. Provides natural language - * understanding technologies to developers, including sentiment analysis, - * entity recognition, and syntax analysis. Currently only English, Spanish, - * and Japanese textual context are supported. Find more information at - * [Google Cloud Natural Language docs](https://cloud.google.com/natural-language/docs/). + * Google Cloud Storage client. Allows you to store and retrieve data on + * Google's infrastructure. Find more information at + * [Google Cloud Storage API docs](https://developers.google.com/storage). * * Example: * ``` - * use Google\Cloud\ServiceBuilder; + * $storage = $cloud->storage(); + * ``` * - * $builder = new ServiceBuilder([ - * 'projectId' => 'myAwesomeProject' - * ]); + * @param array $config Configuration options. See + * {@see Google\Cloud\ServiceBuilder::__construct()} for the available options. + * @return StorageClient + */ + public function storage(array $config = []) + { + return new StorageClient($config ? $this->resolveConfig($config) : $this->config); + } + + /** + * Google Cloud Vision client. Allows you to understand the content of an + * image, classify images into categories, detect text, objects, faces and + * more. Find more information at [Google Cloud Vision docs](https://cloud.google.com/vision/docs/). * - * $language = $builder->naturalLanguage(); + * Example: + * ``` + * $vision = $cloud->vision(); * ``` * * @param array $config Configuration options. See * {@see Google\Cloud\ServiceBuilder::__construct()} for the available options. - * @return NaturalLanguageClient + * @return VisionClient */ - public function naturalLanguage(array $config = []) + public function vision(array $config = []) { - return new NaturalLanguageClient($config ? $this->resolveConfig($config) : $this->config); + return new VisionClient($config ? $this->resolveConfig($config) : $this->config); } /** diff --git a/tests/Datastore/Connection/RestTest.php b/tests/Datastore/Connection/RestTest.php new file mode 100644 index 000000000000..3483e1c6a79a --- /dev/null +++ b/tests/Datastore/Connection/RestTest.php @@ -0,0 +1,84 @@ +requestWrapper = $this->prophesize(RequestWrapper::class); + $this->successBody = '{"canI":"kickIt"}'; + } + + /** + * @dataProvider methodProvider + * @todo revisit this approach + */ + public function testCallBasicMethods($method) + { + $options = []; + $request = new Request('GET', '/somewhere'); + $response = new Response(200, [], $this->successBody); + + $requestBuilder = $this->prophesize(RequestBuilder::class); + $requestBuilder->build( + Argument::type('string'), + Argument::type('string'), + Argument::type('array') + )->willReturn($request); + + $this->requestWrapper->send( + Argument::type(RequestInterface::class), + Argument::type('array') + )->willReturn($response); + + $rest = new Rest(); + $rest->setRequestBuilder($requestBuilder->reveal()); + $rest->setRequestWrapper($this->requestWrapper->reveal()); + + $this->assertEquals(json_decode($this->successBody, true), $rest->$method($options)); + } + + public function methodProvider() + { + return [ + ['allocateIds'], + ['beginTransaction'], + ['commit'], + ['lookup'], + ['rollback'], + ['runQuery'], + ]; + } +} diff --git a/tests/Datastore/DatastoreClientTest.php b/tests/Datastore/DatastoreClientTest.php new file mode 100644 index 000000000000..f444ce4cd77a --- /dev/null +++ b/tests/Datastore/DatastoreClientTest.php @@ -0,0 +1,466 @@ +connection = $this->prophesize(ConnectionInterface::class); + $this->operation = $this->prophesize(Operation::class); + $this->datastore = new DatastoreClientStub(['projectId' => 'foo']); + } + + public function testKey() + { + $key = $this->datastore->key('Foo', 'Bar'); + + $this->assertInstanceOf(Key::class, $key); + + $this->assertEquals($key->keyObject()['path'][0]['kind'], 'Foo'); + $this->assertEquals($key->keyObject()['path'][0]['name'], 'Bar'); + + $key = $this->datastore->key('Foo', '123'); + + $this->assertEquals($key->keyObject()['path'][0]['kind'], 'Foo'); + $this->assertEquals($key->keyObject()['path'][0]['id'], '123'); + + $key = $this->datastore->key('Foo', 123); + + $this->assertEquals($key->keyObject()['path'][0]['kind'], 'Foo'); + $this->assertEquals($key->keyObject()['path'][0]['id'], '123'); + } + + public function testKeyForceType() + { + $key = $this->datastore->key('Foo', '123'); + + $this->assertEquals($key->keyObject()['path'][0]['id'], '123'); + + $key = $this->datastore->key('Foo', '123', [ + 'identifierType' => Key::TYPE_NAME + ]); + + $this->assertEquals($key->keyObject()['path'][0]['name'], '123'); + } + + public function testKeyNamespaceId() + { + $key = $this->datastore->key('Foo', 'Bar', [ + 'namespaceId' => 'MyApp' + ]); + + $this->assertEquals($key->keyObject()['partitionId'], [ + 'projectId' => 'foo', + 'namespaceId' => 'MyApp' + ]); + } + + public function testKeys() + { + $keys = $this->datastore->keys('Person', [ + 'allocateIds' => false + ]); + + $this->assertTrue(is_array($keys)); + $this->assertInstanceOf(Key::class, $keys[0]); + $this->assertEquals($keys[0]->keyObject()['path'][0]['kind'], 'Person'); + } + + public function testKeysMultiple() + { + $keys = $this->datastore->keys('Person', [ + 'allocateIds' => false, + 'number' => 5 + ]); + + $this->assertTrue(is_array($keys)); + $this->assertInstanceOf(Key::class, $keys[0]); + $this->assertEquals(5, count($keys)); + } + + public function testKeysAncestors() + { + $ancestors = [ + ['kind' => 'Parent1', 'id' => '123'], + ['kind' => 'Parent2', 'id' => '321'] + ]; + + $keys = $this->datastore->keys('Person', [ + 'allocateIds' => false, + 'ancestors' => $ancestors + ]); + + $key = $keys[0]; + + $keyAncestors = $key->keyObject()['path']; + array_pop($keyAncestors); + + $this->assertEquals($keyAncestors, $ancestors); + } + + public function testEntity() + { + $key = $this->datastore->key('Person', 'Foo'); + + $entity = $this->datastore->entity($key, [ + 'foo' => 'bar' + ]); + + $this->assertInstanceOf(Entity::class, $entity); + $this->assertEquals($entity['foo'], 'bar'); + } + + public function testAllocateId() + { + $datastore = new DatastoreClientStubNoService; + + $key = $datastore->key('Person'); + + $key = $datastore->allocateId($key); + + $this->assertInstanceOf(Key::class, $key); + $this->assertTrue($datastore->didCallAllocateIds); + } + + public function testAllocateIds() + { + $this->operation->allocateIds(Argument::type('array'), Argument::type('array')) + ->shouldBeCalled() + ->willReturn([]); + + $this->datastore->setOperation($this->operation->reveal()); + + $key = $this->prophesize(Key::class); + $keys = [ + $key->reveal(), + $key->reveal() + ]; + + $res = $this->datastore->allocateIds($keys); + + $this->assertTrue(is_array($res)); + } + + public function testTransaction() + { + $this->connection->beginTransaction(Argument::type('array')) + ->shouldBeCalled() + ->willReturn(['transaction' => '1234']); + + $this->datastore->setConnection($this->connection->reveal()); + + $t = $this->datastore->transaction(); + + $this->assertInstanceOf(Transaction::class, $t); + } + + public function testInsert() + { + $e = $this->prophesize(Entity::class); + + $this->operation->allocateIdsToEntities(Argument::type('array')) + ->willReturn([$e->reveal()]); + + $this->operation->mutate(Argument::exact('insert'), Argument::type('array'), Argument::exact(Entity::class), Argument::exact(null)) + ->shouldBeCalled(); + + $this->operation->commit(Argument::type('array')) + ->shouldBeCalled() + ->willReturn(['mutationResults' => [['version' => '1234']]]); + + $this->datastore->setOperation($this->operation->reveal()); + + $res = $this->datastore->insert($e->reveal()); + + $this->assertEquals($res, '1234'); + } + + /** + * @expectedException DomainException + */ + public function testInsertConflict() + { + $e = $this->prophesize(Entity::class); + + $this->operation->allocateIdsToEntities(Argument::type('array')) + ->willReturn([$e->reveal()]); + + $this->operation->mutate(Argument::exact('insert'), Argument::type('array'), Argument::exact(Entity::class), Argument::exact(null)) + ->shouldBeCalled(); + + $this->operation->commit(Argument::type('array')) + ->shouldBeCalled() + ->willReturn(['mutationResults' => [['version' => '1234', 'conflictDetected' => true]]]); + + $this->datastore->setOperation($this->operation->reveal()); + + $res = $this->datastore->insert($e->reveal()); + } + + public function testInsertBatch() + { + $e = $this->prophesize(Entity::class); + + $this->operation->commit(Argument::type('array')) + ->shouldBeCalled() + ->willReturn(['mutationResults' => [['version' => '1234']]]); + + $this->operation->mutate(Argument::exact('insert'), Argument::type('array'), Argument::exact(Entity::class), Argument::exact(null)) + ->shouldBeCalled(); + + $this->operation->allocateIdsToEntities(Argument::type('array')) + ->willReturn([$e->reveal()]); + + $this->datastore->setOperation($this->operation->reveal()); + + $res = $this->datastore->insertBatch([$e->reveal()]); + + $this->assertEquals($res, ['mutationResults' => [['version' => '1234']]]); + } + + public function testUpdate() + { + $this->operation->commit(Argument::type('array')) + ->shouldBeCalled() + ->willReturn(['mutationResults' => [['version' => '1234']]]); + + $this->operation->mutate(Argument::exact('update'), Argument::type('array'), Argument::exact(Entity::class), Argument::exact(null)) + ->shouldBeCalled(); + + $this->operation->checkOverwrite(Argument::type('array'), Argument::type('bool')) + ->shouldBeCalled(); + + $this->datastore->setOperation($this->operation->reveal()); + + $e = $this->prophesize(Entity::class); + + $res = $this->datastore->update($e->reveal()); + + $this->assertEquals($res, '1234'); + } + + public function testUpdateBatch() + { + $this->operation->commit(Argument::type('array')) + ->shouldBeCalled() + ->willReturn(['mutationResults' => [['version' => '1234']]]); + + $this->operation->mutate(Argument::exact('update'), Argument::type('array'), Argument::exact(Entity::class), Argument::exact(null)) + ->shouldBeCalled(); + + $this->operation->checkOverwrite(Argument::type('array'), Argument::type('bool')) + ->shouldBeCalled(); + + $this->datastore->setOperation($this->operation->reveal()); + + $e = $this->prophesize(Entity::class); + + $res = $this->datastore->updateBatch([$e->reveal()]); + + $this->assertEquals($res, ['mutationResults' => [['version' => '1234']]]); + } + + public function testUpsert() + { + $this->operation->commit(Argument::type('array')) + ->shouldBeCalled() + ->willReturn(['mutationResults' => [['version' => '1234']]]); + + $this->operation->mutate(Argument::exact('upsert'), Argument::type('array'), Argument::exact(Entity::class), Argument::exact(null)) + ->shouldBeCalled(); + + $this->datastore->setOperation($this->operation->reveal()); + + $e = $this->prophesize(Entity::class); + + $res = $this->datastore->upsert($e->reveal()); + + $this->assertEquals($res, '1234'); + } + + public function testUpsertBatch() + { + $this->operation->commit(Argument::type('array')) + ->shouldBeCalled() + ->willReturn(['mutationResults' => [['version' => '1234']]]); + + $this->operation->mutate(Argument::exact('upsert'), Argument::type('array'), Argument::exact(Entity::class), Argument::exact(null)) + ->shouldBeCalled(); + + $this->datastore->setOperation($this->operation->reveal()); + + $e = $this->prophesize(Entity::class); + + $res = $this->datastore->upsertBatch([$e->reveal()]); + + $this->assertEquals($res, ['mutationResults' => [['version' => '1234']]]); + } + + public function testDelete() + { + $this->operation->commit(Argument::type('array')) + ->shouldBeCalled() + ->willReturn(['mutationResults' => [['version' => '1234']]]); + + $this->operation->mutate(Argument::exact('delete'), Argument::type('array'), Argument::exact(Key::class), Argument::exact(null)) + ->shouldBeCalled(); + + $this->datastore->setOperation($this->operation->reveal()); + + $key = $this->prophesize(Key::class); + + $res = $this->datastore->delete($key->reveal()); + + $this->assertEquals($res, '1234'); + } + + public function testDeleteBatch() + { + $this->operation->commit(Argument::type('array')) + ->shouldBeCalled() + ->willReturn(['mutationResults' => [['version' => '1234']]]); + + $this->operation->mutate(Argument::exact('delete'), Argument::type('array'), Argument::exact(Key::class), Argument::exact(null)) + ->shouldBeCalled(); + + $this->datastore->setOperation($this->operation->reveal()); + + $key = $this->prophesize(Key::class); + + $res = $this->datastore->deleteBatch([$key->reveal()]); + + $this->assertEquals($res, ['mutationResults' => [['version' => '1234']]]); + } + + public function testLookup() + { + $ds = new DatastoreClientStubNoService; + + $key = $ds->key('Kind', 'Value'); + $res = $ds->lookup($key); + + $this->assertInstanceOf(Entity::class, $res); + $this->assertTrue($ds->didCallLookupBatch); + $this->assertEquals($key, $ds->keys[0]); + } + + public function testLookupBatch() + { + $body = json_decode(file_get_contents(__DIR__ .'/../fixtures/datastore/entity-batch-lookup.json'), true); + $this->operation->lookup(Argument::type('array'), Argument::type('array')) + ->shouldBeCalled() + ->willReturn(['foo']); + + $this->datastore->setOperation($this->operation->reveal()); + + $key = $this->prophesize(Key::class); + + $res = $this->datastore->lookupBatch([$key->reveal()]); + + $this->assertEquals($res, ['foo']); + } + + public function testQuery() + { + $q = $this->datastore->query(); + + $this->assertInstanceOf(Query::class, $q); + } + + public function testGqlQuery() + { + $q = $this->datastore->gqlQuery('foo'); + $this->assertInstanceOf(GqlQuery::class, $q); + } + + public function testRunQuery() + { + $queryResult = json_decode(file_get_contents(__DIR__ .'/../fixtures/datastore/query-results.json'), true); + + $this->operation->runQuery(Argument::type(QueryInterface::class), Argument::type('array')) + ->shouldBeCalled() + ->willReturn('foo'); + + $this->datastore->setOperation($this->operation->reveal()); + + $q = $this->datastore->query(); + $res = $this->datastore->runQuery($q); + + $this->assertEquals($res, 'foo'); + } +} + +class DatastoreClientStub extends DatastoreClient +{ + public function setConnection($connection) + { + $this->connection = $connection; + } + + public function setOperation($operation) + { + $this->operation = $operation; + } +} + +class DatastoreClientStubNoService extends DatastoreClientStub +{ + public $didCallAllocateIds = false; + + public function allocateIds(array $keys, array $options = []) + { + $this->didCallAllocateIds = true; + return $keys; + } + + public $didCallBeginTransaction = false; + + public function beginTransaction(array $options = []) + { + $this->didCallBeginTransaction = true; + return new Transaction($this->connection, '', ''); + } + + public $didCallLookupBatch = false; + public $keys = []; + public function lookupBatch(array $keys, array $options = []) + { + $this->keys = $keys; + $this->didCallLookupBatch = true; + return ['found' => [$this->entity($this->key('Kind', 'Value'), ['foo' => 'bar'])]]; + } +} diff --git a/tests/Datastore/DatastoreTraitTest.php b/tests/Datastore/DatastoreTraitTest.php new file mode 100644 index 000000000000..849232d12a1f --- /dev/null +++ b/tests/Datastore/DatastoreTraitTest.php @@ -0,0 +1,57 @@ +stub = new DatastoreTraitStub; + } + + public function testPartitionId() + { + $res = $this->stub->call('partitionId', [ + 'foo', 'bar' + ]); + + $this->assertTrue(is_array($res)); + $this->assertEquals('foo', $res['projectId']); + $this->assertEquals('bar', $res['namespaceId']); + } +} + +class DatastoreTraitStub +{ + use DatastoreTrait; + + public function call($fn, array $args) + { + return call_user_func_array([$this, $fn], $args); + } +} diff --git a/tests/Datastore/EntityMapperTest.php b/tests/Datastore/EntityMapperTest.php new file mode 100644 index 000000000000..d79c23b6694d --- /dev/null +++ b/tests/Datastore/EntityMapperTest.php @@ -0,0 +1,347 @@ +mapper = new EntityMapper('foo', true); + } + + public function testObjectToRequest() + { + $key = new Key('foo', [ + 'path' => [['kind' => 'kind', 'id' => '2']] + ]); + + $entity = new Entity($key, [ + 'key' => 'val' + ]); + + $res = $this->mapper->objectToRequest($entity); + $this->assertEquals($key, $res['key']); + $this->assertEquals('val', $res['properties']['key']['stringValue']); + } + + public function testConvertValueTimestamp() + { + $type = 'timestampValue'; + $val = (new \DateTime())->format(\DateTime::RFC3339); + + $res = $this->mapper->convertValue($type, $val); + $this->assertEquals($val, $res->format(\DateTime::RFC3339)); + } + + public function testConvertValueKey() + { + $type = 'keyValue'; + $val = [ + 'partitionId' => [ + 'namespaceId' => 'bar' + ], + 'path' => [['kind' => 'kind', 'id' => '2']] + ]; + + $res = $this->mapper->convertValue($type, $val); + $this->assertInstanceOf(Key::class, $res); + + $arr = $res->keyObject(); + $this->assertEquals('bar', $arr['partitionId']['namespaceId']); + $this->assertEquals([['kind' => 'kind', 'id' => '2']], $arr['path']); + } + + public function testConvertValueGeo() + { + $type = 'geoPointValue'; + $val = [ + 'latitude' => 0.1, + 'longitude' => 1.0 + ]; + + $res = $this->mapper->convertValue($type, $val); + $this->assertInstanceOf(GeoPoint::class, $res); + $this->assertEquals($val, $res->point()); + } + + public function testConvertValueEntityWithKey() + { + $type = 'entityValue'; + $val = [ + 'key' => [ + 'partitionId' => [ + 'namespaceId' => 'bar' + ], + 'path' => [['kind' => 'kind', 'id' => '2']] + ], + 'properties' => [ + 'prop' => [ + 'stringValue' => 'test' + ] + ] + ]; + + $res = $this->mapper->convertValue($type, $val); + $this->assertInstanceOf(Entity::class, $res); + $this->assertInstanceOf(Key::class, $res->key()); + + $key = $res->key()->keyObject(); + $this->assertEquals('bar', $key['partitionId']['namespaceId']); + $this->assertEquals([['kind' => 'kind', 'id' => '2']], $key['path']); + + $this->assertEquals('test', $res['prop']); + } + + public function testConvertValueEntityWithIncompleteKey() + { + $type = 'entityValue'; + $val = [ + 'key' => [ + 'partitionId' => [ + 'namespaceId' => 'bar' + ], + 'path' => [['kind' => 'kind']] + ], + 'properties' => [ + 'prop' => [ + 'stringValue' => 'test' + ] + ] + ]; + + $res = $this->mapper->convertValue($type, $val); + $this->assertInstanceOf(Entity::class, $res); + $this->assertInstanceOf(Key::class, $res->key()); + + $key = $res->key()->keyObject(); + $this->assertEquals('bar', $key['partitionId']['namespaceId']); + $this->assertEquals([['kind' => 'kind']], $key['path']); + + $this->assertEquals('test', $res['prop']); + } + + public function testConvertValueEntityWithoutKey() + { + $type = 'entityValue'; + $val = [ + 'properties' => [ + 'prop' => [ + 'stringValue' => 'test' + ] + ] + ]; + + $res = $this->mapper->convertValue($type, $val); + $this->assertTrue(is_array($res)); + $this->assertEquals('test', $res['prop']); + } + + public function testConvertValueDouble() + { + $type = 'doubleValue'; + $val = 1.1; + + $res = $this->mapper->convertValue($type, $val); + $this->assertTrue(is_float($res)); + $this->assertEquals(1.1, $res); + } + + public function testConvertValueDoubleWithCast() + { + $type = 'doubleValue'; + $val = 1; + + $res = $this->mapper->convertValue($type, $val); + $this->assertTrue(is_float($res)); + $this->assertEquals((float)1, $res); + } + + public function testConvertValueInteger() + { + $type = 'integerValue'; + $val = 1; + + $res = $this->mapper->convertValue($type, $val); + $this->assertEquals(1, $res); + } + + public function testArrayValue() + { + $type = 'arrayValue'; + $val = [ + 'values' => [ + ['stringValue' => 'foo'], + ['stringValue' => 'bar'] + ] + ]; + + $res = $this->mapper->convertValue($type, $val); + $this->assertTrue(is_array($res)); + $this->assertEquals(['foo', 'bar'], $res); + } + + + public function testValueObjectBool() + { + $bool = $this->mapper->valueObject(true); + + $this->assertTrue($bool['booleanValue']); + } + + public function testValueObjectInt() + { + $int = $this->mapper->valueObject(1); + + $this->assertEquals(1, $int['integerValue']); + } + + public function testValueObjectDouble() + { + $double = $this->mapper->valueObject(1.1); + + $this->assertEquals(1.1, $double['doubleValue']); + } + + public function testValueObjectString() + { + $string = $this->mapper->valueObject('foo'); + + $this->assertEquals('foo', $string['stringValue']); + } + + public function testValueObjectArrayEntityValue() + { + $entity = $this->mapper->valueObject([ + 'key1' => 'val1', + 'key2' => 'val2' + ]); + + $this->assertEquals('entityValue', key($entity)); + $this->assertEquals('val1', $entity['entityValue']['properties']['key1']['stringValue']); + $this->assertEquals('val2', $entity['entityValue']['properties']['key2']['stringValue']); + } + + public function testValueObjectArrayArrayValue() + { + $array = $this->mapper->valueObject([ 'bar', 1 ]); + + $this->assertEquals('bar', $array['arrayValue']['values'][0]['stringValue']); + $this->assertEquals(1, $array['arrayValue']['values'][1]['integerValue']); + } + + public function testValueObjectNull() + { + $null = $this->mapper->valueObject(null); + + $this->assertNull($null['nullValue']); + } + + public function testValueObjectResource() + { + $string = 'test data'; + + $stream = fopen('php://memory','r+'); + fwrite($stream, $string); + rewind($stream); + + $res = $this->mapper->valueObject($stream); + + $this->assertEquals(base64_encode($string), $res['blobValue']); + } + + public function testValueObjectResourceNotEncoded() + { + $string = 'test data'; + + $stream = fopen('php://memory','r+'); + fwrite($stream, $string); + rewind($stream); + + $mapper = new EntityMapper('foo', false); + $res = $mapper->valueObject($stream); + + $this->assertEquals($string, $res['blobValue']); + } + + public function testValueExcludeFromIndexes() + { + $res = $this->mapper->valueObject('hello', true); + + $this->assertTrue($res['excludeFromIndexes']); + + $res = $this->mapper->valueObject('hello', false); + + $this->assertFalse(isset($res['excludeFromIndexes'])); + } + + public function testObjectPropertyDateTime() + { + $res = $this->mapper->valueObject(new \DateTimeImmutable); + + $this->assertEquals((new \DateTimeImmutable())->format(\DateTime::RFC3339), $res['timestampValue']); + } + + public function testObjectPropertyKey() + { + $key = $this->prophesize(Key::class); + $key->keyObject()->willReturn('foo'); + + $res = $this->mapper->valueObject($key->reveal()); + + $this->assertEquals($res['keyValue'], 'foo'); + } + + public function testObjectPropertyGeoPoint() + { + $point = new GeoPoint(1.0, 0.1); + $res = $this->mapper->objectProperty($point); + + $this->assertEquals([ + 'geoPointValue' => $point->point() + ], $res); + } + + public function testObjectPropertyEntity() + { + $key = new Key('foo', ['path' => [['kind' => 'kind', 'id' => 'id']]]); + $entity = new Entity($key, [ + 'key' => 'val' + ]); + + $res = $this->mapper->objectProperty($entity); + $this->assertEquals('val', $res['entityValue']['properties']['key']['stringValue']); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testObjectPropertyInvalidType() + { + $this->mapper->valueObject($this); + } +} diff --git a/tests/Datastore/EntityTest.php b/tests/Datastore/EntityTest.php new file mode 100644 index 000000000000..3bbc1f8a6d3f --- /dev/null +++ b/tests/Datastore/EntityTest.php @@ -0,0 +1,108 @@ +key = new Key('foo', ['path' => [ + ['kind' => 'kind', 'name' => 'name'] + ]]); + + $this->mapper = new EntityMapper('foo', true); + } + + public function testCreateEntity() + { + $entity = new Entity($this->key, [ + 'foo' => "bar" + ]); + + $this->assertEquals('bar', $entity['foo']); + + $entity['test'] = 'val'; + + $this->assertEquals('val', $entity['test']); + + $this->assertNull($entity['doesntExist']); + + $this->assertFalse(isset($entity['doesntExist'])); + $this->assertTrue(isset($entity['test'])); + + unset($entity['test']); + $this->assertFalse(isset($entity['test'])); + + $entity->magicProperty = 'magic value'; + $this->assertEquals('magic value', $entity->magicProperty); + + $this->assertNull($entity->nonExistentMagicProperty); + $this->assertFalse(isset($entity->nonExistentMagicProperty)); + + $this->assertTrue(isset($entity->magicProperty)); + + unset($entity->magicProperty); + $this->assertFalse(isset($entity->magicProperty)); + } + + public function testGet() + { + $data = ['foo' => 'bar']; + + $entity = new Entity($this->key, $data); + $this->assertEquals($data, $entity->get()); + } + + public function testSet() + { + $data = ['foo' => 'bar']; + + $entity = new Entity($this->key, []); + $entity->set($data); + $this->assertEquals($data, $entity->get()); + } + + public function testKey() + { + $entity = new Entity($this->key, []); + $this->assertEquals($this->key, $entity->key()); + } + + public function testCursor() + { + $entity = new Entity($this->key, []); + $this->assertNull($entity->cursor()); + + $entity = new Entity($this->key, [], [ + 'cursor' => 'foo' + ]); + + $this->assertEquals('foo', $entity->cursor()); + } +} diff --git a/tests/Datastore/GeoPointTest.php b/tests/Datastore/GeoPointTest.php new file mode 100644 index 000000000000..24d8fbc33a46 --- /dev/null +++ b/tests/Datastore/GeoPointTest.php @@ -0,0 +1,88 @@ +assertEquals(1.1, $point->latitude()); + $this->assertEquals(2.2, $point->longitude()); + } + + public function testGeoPointSetters() + { + $point = new GeoPoint(1.1, 2.2); + $point->setLatitude(3.3); + $this->assertEquals(3.3, $point->latitude()); + + $point->setLongitude(4.4); + $this->assertEquals(4.4, $point->longitude()); + } + + public function testPoint() + { + $point = new GeoPoint(1.1, 2.2); + $this->assertEquals($point->point(), [ + 'latitude' => 1.1, + 'longitude' => 2.2 + ]); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testCheckContextLatitude() + { + $point = new GeoPoint(1.1, 2.2); + $point->latitude(222.33); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testCheckContextLongitude() + { + $point = new GeoPoint(1.1, 2.2); + $point->longitude(222.33); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testInvalidTypeLatitude() + { + $point = new GeoPoint(1.1, 2.2); + $point->setLatitude('foo'); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testInvalidTypeLongitude() + { + $point = new GeoPoint(1.1, 2.2); + $point->setLongitude('bar'); + } +} diff --git a/tests/Datastore/KeyTest.php b/tests/Datastore/KeyTest.php new file mode 100644 index 000000000000..f0cd9dccf9f4 --- /dev/null +++ b/tests/Datastore/KeyTest.php @@ -0,0 +1,281 @@ + [ + ['kind' => 'Person'] + ] + ]); + + $this->assertEquals($key->keyObject()['path'][0]['kind'], 'Person'); + } + + public function testKeyNamespaceId() + { + $key = new Key('foo', [ + 'namespaceId' => 'MyApp' + ]); + + $this->assertEquals($key->keyObject()['partitionId'], [ + 'projectId' => 'foo', + 'namespaceId' => 'MyApp' + ]); + } + + public function testPathElement() + { + $key = new Key('foo'); + + $this->assertEmpty($key->keyObject()['path']); + + $key->pathElement('foo', 'bar'); + + $this->assertEquals(1, count($key->keyObject()['path'])); + $this->assertEquals(['kind' => 'foo', 'name' => 'bar'], $key->keyObject()['path'][0]); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testInvalidPathElementAddition() + { + $key = new Key('foo', [ + 'path' => [ + ['kind' => 'thing'] + ] + ]); + + $key->pathElement('foo', 'bar'); + } + + public function testAncestor() + { + $key = new Key('foo', [ + 'path' => [ + ['kind' => 'thing'] + ] + ]); + + $key->ancestor('Hello', 'World'); + + $this->assertEquals(['kind' => 'Hello', 'name' => 'World'], $key->keyObject()['path'][0]); + $this->assertEquals(['kind' => 'thing'], $key->keyObject()['path'][1]); + } + + public function testAncestorKey() + { + $ancestorPath = [ + ['kind' => 'Kind', 'id' => 'ID'] + ]; + + $ancestor = $this->prophesize(Key::class); + $ancestor->path()->willReturn($ancestorPath); + $ancestor->state()->willReturn(Key::STATE_COMPLETE); + + $key = new Key('foo', [ + 'path' => [ + ['kind' => 'foo'] + ] + ]); + + $key->ancestorKey($ancestor->reveal()); + + $path = $key->path(); + + $expected = $ancestorPath; + $expected[] = ['kind' => 'foo']; + + $this->assertEquals($path, $expected); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testAncestorKeyIncompletePath() + { + $ancestor = $this->prophesize(Key::class); + $ancestor->state()->willReturn(Key::STATE_INCOMPLETE); + + $key = new Key('foo'); + + $key->ancestorKey($ancestor->reveal()); + } + + public function testPathElementForceType() + { + $key = new Key('foo'); + $key->pathElement('Robots', '1000', Key::TYPE_NAME); + $key->pathElement('Robots', '1000'); + + $this->assertEquals(['kind' => 'Robots', 'name' => '1000'], $key->keyObject()['path'][0]); + $this->assertEquals(['kind' => 'Robots', 'id' => '1000'], $key->keyObject()['path'][1]); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testPathElementInvalidIdentifierType() + { + $key = new Key('foo'); + $key->pathElement('Robots', '1000', 'nothanks'); + } + + public function testNormalizedPath() + { + $key = new Key('foo', [ + 'path' => ['kind' => 'foo', 'id' => 1] + ]); + + $this->assertEquals([['kind' => 'foo', 'id' => 1]], $key->path()); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testMissingKind() + { + $key = new Key('foo', [ + 'path' => [ + ['id' => '1'] + ] + ]); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testElementMissingIdentifier() + { + $key = new Key('foo', [ + 'path' => [ + ['kind' => 'foo'], + ['kind' => 'foo', 'id' => 1] + ] + ]); + } + + public function testJsonSerialize() + { + $key = new Key('foo'); + $key->pathElement('Robots', '1000', Key::TYPE_NAME); + + $this->assertEquals($key->jsonSerialize(), $key->keyObject()); + } + + public function testStateComplete() + { + $key = new Key('foo', [ + 'path' => [ + ['kind' => 'foo', 'id' => 1] + ] + ]); + + $this->assertEquals($key->state(), key::STATE_COMPLETE); + } + + public function testStateIncomplete() + { + $key = new Key('foo', [ + 'path' => [ + ['kind' => 'foo'] + ] + ]); + + $this->assertEquals($key->state(), key::STATE_INCOMPLETE); + } + + public function testPath() + { + $key = new Key('foo', [ + 'path' => [ + ['kind' => 'foo', 'id' => 1] + ] + ]); + + $this->assertEquals($key->path(), [ + ['kind' => 'foo', 'id' => 1] + ]); + } + + public function testPathEnd() + { + $key = new Key('foo', [ + 'path' => [ + ['kind' => 'foo', 'id' => 2], + ['kind' => 'foo', 'id' => 1] + ] + ]); + + $this->assertEquals($key->pathEnd(), ['kind' => 'foo', 'id' => 1]); + } + + public function testSetLastElementIdentifier() + { + $key = new Key('foo', [ + 'path' => [ + ['kind' => 'foo', 'id' => 2], + ['kind' => 'foo'] + ] + ]); + + $key->setLastElementIdentifier(1); + + $this->assertEquals($key->path(), [ + ['kind' => 'foo', 'id' => 2], + ['kind' => 'foo', 'id' => 1] + ]); + } + + public function testSetLastElementIdentifierTypeName() + { + $key = new Key('foo', [ + 'path' => [ + ['kind' => 'foo', 'id' => 2], + ['kind' => 'foo'] + ] + ]); + + $key->setLastElementIdentifier(1, Key::TYPE_NAME); + + $this->assertEquals($key->path(), [ + ['kind' => 'foo', 'id' => 2], + ['kind' => 'foo', 'name' => 1] + ]); + } + + public function testToString() + { + $key = new Key('foo', [ + 'path' => [ + ['kind' => 'foo', 'id' => 2], + ] + ]); + + $this->assertEquals('[ [foo: 2] ]', (string) $key); + } +} diff --git a/tests/Datastore/OperationTest.php b/tests/Datastore/OperationTest.php new file mode 100644 index 000000000000..c88f906f26c7 --- /dev/null +++ b/tests/Datastore/OperationTest.php @@ -0,0 +1,630 @@ +connection = $this->prophesize(ConnectionInterface::class); + $this->mapper = new EntityMapper('foo', true); + $this->operation = new OperationStub($this->connection->reveal(), 'foo', '', $this->mapper); + } + + public function testKey() + { + $key = $this->operation->key('Foo', 'Bar'); + + $this->assertInstanceOf(Key::class, $key); + $this->assertEquals('Foo', $key->path()[0]['kind']); + $this->assertEquals('Bar', $key->path()[0]['name']); + } + + public function testKeys() + { + $keys = $this->operation->keys('Foo'); + $this->assertEquals(1, count($keys)); + $this->assertInstanceOf(Key::class, $keys[0]); + } + + public function testKeysNumber() + { + $keys = $this->operation->keys('Foo', [ + 'number' => 10 + ]); + + $this->assertEquals(10, count($keys)); + } + + public function testKeysAncestors() + { + $keys = $this->operation->keys('Foo', [ + 'ancestors' => [ + ['kind' => 'Kind', 'id' => '10'] + ] + ]); + + $this->assertEquals($keys[0]->path(), [ + ['kind' => 'Kind', 'id' => '10'], + ['kind' => 'Foo'] + ]); + } + + public function testKeysId() + { + $keys = $this->operation->keys('Foo', [ + 'id' => '10' + ]); + + $this->assertEquals($keys[0]->path(), [ + ['kind' => 'Foo', 'id' => '10'] + ]); + } + + public function testKeysName() + { + $keys = $this->operation->keys('Foo', [ + 'name' => '10' + ]); + + $this->assertEquals($keys[0]->path(), [ + ['kind' => 'Foo', 'name' => '10'] + ]); + } + + public function testEntity() + { + $key = $this->prophesize(Key::class); + $e = $this->operation->entity($key->reveal()); + + $this->assertInstanceOf(Entity::class, $e); + } + + public function testEntityWithKind() + { + $e = $this->operation->entity('Foo'); + $this->assertInstanceOf(Entity::class, $e); + $this->assertEquals($e->key()->state(), Key::STATE_INCOMPLETE); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testInvalidKeyType() + { + $this->operation->entity(1); + } + + public function testEntityCustomClass() + { + $e = $this->operation->entity('Foo', [], [ + 'className' => MyEntity::class + ]); + + $this->assertInstanceOf(MyEntity::class, $e); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testEntityCustomClassInvalidType() + { + $e = $this->operation->entity('Foo', [], [ + 'className' => Operation::class + ]); + } + + public function testAllocateIds() + { + $keys = [ + $this->operation->key('foo') + ]; + + $this->connection->allocateIds(Argument::type('array')) + ->shouldBeCalled() + ->willReturn([ + 'keys' => [ + [ + 'path' => [ + ['kind' => 'foo', 'id' => '1'] + ] + ] + ] + ]); + + $this->operation->setConnection($this->connection->reveal()); + + $res = $this->operation->allocateIds($keys); + + $this->assertEquals($res[0]->state(), Key::STATE_COMPLETE); + $this->assertEquals($res[0]->path()[0]['id'], '1'); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testAllocateIdsCompleteKey() + { + $keys = [ + $this->operation->key('foo', 'Bar') + ]; + + $this->operation->allocateIds($keys); + } + + public function testLookup() + { + $keys = [ + $this->operation->key('foo', 'Bar') + ]; + + $this->connection->lookup(Argument::type('array')) + ->shouldBeCalled() + ->willReturn([]); + + $this->operation->setConnection($this->connection->reveal()); + + $res = $this->operation->lookup($keys); + + $this->assertTrue(is_array($res)); + } + + public function testLookupFound() + { + $body = json_decode(file_get_contents(__DIR__ .'/../fixtures/datastore/entity-batch-lookup.json'), true); + $this->connection->lookup(Argument::any())->willReturn([ + 'found' => $body + ]); + + $this->operation->setConnection($this->connection->reveal()); + + $key = $this->operation->key('Kind', 'ID'); + $res = $this->operation->lookup([$key]); + + $this->assertTrue(is_array($res)); + $this->assertTrue(isset($res['found']) && is_array($res['found'])); + + $this->assertInstanceOf(Entity::class, $res['found'][0]); + $this->assertEquals($res['found'][0]['Number'], $body[0]['entity']['properties']['Number']['stringValue']); + + $this->assertInstanceOf(Entity::class, $res['found'][1]); + $this->assertEquals($res['found'][1]['Number'], $body[1]['entity']['properties']['Number']['stringValue']); + } + + public function testLookupMissing() + { + $body = json_decode(file_get_contents(__DIR__ .'/../fixtures/datastore/entity-batch-lookup.json'), true); + $this->connection->lookup(Argument::any())->willReturn([ + 'missing' => $body + ]); + + $this->operation->setConnection($this->connection->reveal()); + + $key = $this->operation->key('Kind', 'ID'); + + $res = $this->operation->lookup([$key]); + + $this->assertTrue(is_array($res)); + $this->assertTrue(isset($res['missing']) && is_array($res['missing'])); + + $this->assertInstanceOf(Key::class, $res['missing'][0]); + $this->assertInstanceOf(Key::class, $res['missing'][1]); + } + + public function testLookupDeferred() + { + $body = json_decode(file_get_contents(__DIR__ .'/../fixtures/datastore/entity-batch-lookup.json'), true); + $this->connection->lookup(Argument::any())->willReturn([ + 'deferred' => [ $body[0]['entity']['key'] ] + ]); + + $this->operation->setConnection($this->connection->reveal()); + + $key = $this->operation->key('Kind', 'ID'); + + $res = $this->operation->lookup([$key]); + + $this->assertTrue(is_array($res)); + $this->assertTrue(isset($res['deferred']) && is_array($res['deferred'])); + $this->assertInstanceOf(Key::class, $res['deferred'][0]); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testLookupInvalidKey() + { + $key = $this->operation->key('Foo'); + + $this->operation->lookup([$key]); + } + + public function testRunQuery() + { + $queryResult = json_decode(file_get_contents(__DIR__ .'/../fixtures/datastore/query-results.json'), true); + $this->connection->runQuery(Argument::type('array')) + ->willReturn($queryResult['notPaged']); + + $this->operation->setConnection($this->connection->reveal()); + + $q = $this->prophesize(QueryInterface::class); + $q->queryKey()->shouldBeCalled()->willReturn('query'); + $q->queryObject()->shouldBeCalled()->willReturn([]); + $q->canPaginate()->willReturn(true); + $q->start(Argument::any()); + + $res = $this->operation->runQuery($q->reveal()); + + $this->assertInstanceOf(\Generator::class, $res); + + $arr = iterator_to_array($res); + $this->assertEquals(count($arr), 2); + $this->assertInstanceOf(Entity::class, $arr[0]); + } + + public function testRunQueryPaged() + { + $queryResult = json_decode(file_get_contents(__DIR__ .'/../fixtures/datastore/query-results.json'), true); + $this->connection->runQuery(Argument::type('array')) + ->will(function($args, $mock) use ($queryResult) { + // The 2nd call will return the 2nd page of results! + $mock->runQuery(Argument::type('array')) + ->willReturn($queryResult['paged'][1]); + return $queryResult['paged'][0]; + }); + + $this->operation->setConnection($this->connection->reveal()); + + $q = $this->prophesize(QueryInterface::class); + $q->queryKey()->shouldBeCalled()->willReturn('query'); + $q->queryObject()->shouldBeCalled()->willReturn([]); + $q->canPaginate()->willReturn(true); + $q->start(Argument::any())->willReturn(null); + + $res = $this->operation->runQuery($q->reveal()); + + $this->assertInstanceOf(\Generator::class, $res); + + $arr = iterator_to_array($res); + $this->assertEquals(count($arr), 3); + $this->assertInstanceOf(Entity::class, $arr[0]); + } + + public function testRunQueryNoResults() + { + $queryResult = json_decode(file_get_contents(__DIR__ .'/../fixtures/datastore/query-results.json'), true); + $this->connection->runQuery(Argument::type('array')) + ->willReturn($queryResult['noResults']); + + $this->operation->setConnection($this->connection->reveal()); + + $q = $this->prophesize(QueryInterface::class); + $q->queryKey()->shouldBeCalled()->willReturn('query'); + $q->queryObject()->shouldBeCalled()->willReturn([]); + + $res = $this->operation->runQuery($q->reveal()); + + $this->assertInstanceOf(\Generator::class, $res); + + $arr = iterator_to_array($res); + $this->assertEquals(count($arr), 0); + } + + public function testCommit() + { + $this->connection->commit(Argument::that(function($arg) { + if ($arg['mode'] !== 'NON_TRANSACTIONAL') return false; + + if (count($arg['mutations']) > 0) return false; + + return true; + })) + ->shouldBeCalled() + ->willReturn(['foo']); + + $this->operation->setConnection($this->connection->reveal()); + + $this->assertEquals(['foo'], $this->operation->commit()); + } + + public function testCommitInTransaction() + { + $this->connection->commit(Argument::that(function($arg) { + if ($arg['mode'] !== 'TRANSACTIONAL') return false; + + if (count($arg['mutations']) > 0) return false; + + return true; + })) + ->shouldBeCalled() + ->willReturn(['foo']); + + $this->operation->setConnection($this->connection->reveal()); + + $this->operation->commit([ + 'transaction' => '1234' + ]); + } + + public function testCommitWithMutation() + { + $this->connection->commit(Argument::that(function($arg) { + if (count($arg['mutations']) !== 1) return false; + + return true; + })) + ->shouldBeCalled() + ->willReturn(['foo']); + + $this->operation->setConnection($this->connection->reveal()); + + $key = $this->prophesize(Key::class); + $e = new Entity($key->reveal()); + + $this->operation->mutate('insert', [$e], Entity::class, null); + + $this->operation->commit(); + + // mutations should be empty the 2nd time. + $this->connection->commit(Argument::that(function($arg) { + if (count($arg['mutations']) > 0) return false; + + return true; + })) + ->shouldBeCalled() + ->willReturn(['foo']); + + $this->operation->setConnection($this->connection->reveal()); + + $this->operation->commit(); + } + + public function testAllocateIdsToEntities() + { + $this->connection->allocateIds(Argument::that(function ($arg) { + if (count($arg['keys']) !== 1) return false; + + return true; + })) + ->shouldBeCalled() + ->willReturn([ + 'keys' => [ + [ + 'path' => [ + ['kind' => 'foo', 'id' => '1'] + ] + ] + ] + ]); + + $this->operation->setConnection($this->connection->reveal()); + + $entities = [ + $this->operation->entity($this->operation->key('Foo', 'Bar')), + $this->operation->entity($this->operation->key('Foo')) + ]; + + $res = $this->operation->allocateIdsToEntities($entities); + + $this->assertInstanceOf(Entity::class, $res[0]); + $this->assertInstanceOf(Entity::class, $res[1]); + + $this->assertEquals($res[0]->key()->state(), Key::STATE_COMPLETE); + $this->assertEquals($res[1]->key()->state(), Key::STATE_COMPLETE); + } + + public function testMutate() + { + $this->connection->commit(Argument::that(function ($arg) { + if (count($arg['mutations']) !== 1) return false; + + if (!isset($arg['mutations'][0]['insert'])) return false; + + return true; + }))->willReturn('foo'); + + $this->operation->setConnection($this->connection->reveal()); + + $key = $this->prophesize(Key::class); + $e = new Entity($key->reveal()); + + $this->operation->mutate('insert', [$e], Entity::class, null); + $this->operation->commit(); + } + + public function testMutateWithBaseVersion() + { + $this->connection->commit(Argument::that(function ($arg) { + if ($arg['mutations'][0]['baseVersion'] !== 1) return false; + + return true; + }))->willReturn('foo'); + + $this->operation->setConnection($this->connection->reveal()); + + $key = $this->prophesize(Key::class); + $e = new Entity($key->reveal(), [], [ + 'baseVersion' => 1 + ]); + + $this->operation->mutate('insert', [$e], Entity::class); + $this->operation->commit(); + } + + public function testMutateWithKey() + { + $this->connection->commit(Argument::that(function ($arg) { + if (!isset($arg['mutations'][0]['delete'])) return false; + if (!isset($arg['mutations'][0]['delete']['path'])) return false; + + return true; + }))->willReturn('foo'); + + $this->operation->setConnection($this->connection->reveal()); + + $key = new Key('foo', [ + 'path' => [['kind' => 'foo', 'id' => 1]] + ]); + + $this->operation->mutate('delete', [$key], Key::class); + $this->operation->commit(); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testMutateInvalidType() + { + $this->operation->mutate('foo', [(object)[]], \stdClass::class); + } + + public function testCheckOverwrite() + { + $e = $this->prophesize(Entity::class); + $e->populatedByService()->willReturn(true); + + $this->operation->checkOverwrite([$e->reveal()]); + } + + public function testCheckOverwriteWithFlagEnabled() + { + $e = $this->prophesize(Entity::class); + $e->populatedByService()->willReturn(false); + + $this->operation->checkOverwrite([$e->reveal()], true); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testCheckOverwriteWithException() + { + $e = $this->prophesize(Entity::class); + $e->populatedByService()->willReturn(false); + $e->key()->willReturn('foo'); + + $this->operation->checkOverwrite([$e->reveal()]); + } + + public function testMapEntityResult() + { + $res = json_decode(file_get_contents(__DIR__ .'/../fixtures/datastore/entity-result.json'), true); + + $this->connection->lookup(Argument::type('array')) + ->willReturn([ + 'found' => $res + ]); + + $this->operation->setConnection($this->connection->reveal()); + + $k = $this->prophesize(Key::class); + $k->state()->willReturn(Key::STATE_COMPLETE); + + $entity = $this->operation->lookup([$k->reveal()]); + $this->assertInstanceOf(Entity::class, $entity['found'][0]); + + $this->assertEquals($entity['found'][0]->baseVersion(), $res[0]['version']); + $this->assertEquals($entity['found'][0]->cursor(), $res[0]['cursor']); + $this->assertEquals($entity['found'][0]->prop, $res[0]['entity']['properties']['prop']['stringValue']); + } + + public function testTransactionInReadOptions() + { + $this->connection->lookup(Argument::that(function ($arg) { + if (!isset($arg['readOptions']['transaction'])) return false; + + return true; + })) + ->willReturn([]); + + $this->operation->setConnection($this->connection->reveal()); + + $k = $this->prophesize(Key::class); + $k->state()->willReturn(Key::STATE_COMPLETE); + $this->operation->lookup([$k->reveal()], [ + 'transaction' => '1234' + ]); + } + + public function testNonTransactionalReadOptions() + { + $this->connection->lookup(Argument::that(function ($arg) { + if (!isset($arg['readOptions']['transaction'])) return true; + + return true; + })) + ->willReturn([]); + + $this->operation->setConnection($this->connection->reveal()); + + $k = $this->prophesize(Key::class); + $k->state()->willReturn(Key::STATE_COMPLETE); + $this->operation->lookup([$k->reveal()]); + } + + public function testReadConsistencyInReadOptions() + { + $this->connection->lookup(Argument::that(function ($arg) { + if ($arg['readOptions']['readConsistency'] !== 'test') return true; + + return true; + })) + ->willReturn([]); + + $this->operation->setConnection($this->connection->reveal()); + + $k = $this->prophesize(Key::class); + $k->state()->willReturn(Key::STATE_COMPLETE); + $this->operation->lookup([$k->reveal()], [ + 'readConsistency' => 'test' + ]); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testInvalidBatchType() + { + $this->operation->lookup(['foo']); + } +} + +class OperationStub extends Operation +{ + public function setConnection($connection) + { + $this->connection = $connection; + } +} + +class MyEntity extends Entity {} diff --git a/tests/Datastore/Query/GqlQueryTest.php b/tests/Datastore/Query/GqlQueryTest.php new file mode 100644 index 000000000000..d93d67d460c5 --- /dev/null +++ b/tests/Datastore/Query/GqlQueryTest.php @@ -0,0 +1,94 @@ +mapper = new EntityMapper('foo', true); + } + + public function testBindingTypeAutomaticDetectionNamed() + { + $query = new GqlQuery($this->mapper, 'SELECT * FROM foo', [ + 'bindings' => [ + 'bind' => 'this' + ] + ]); + + $res = $query->queryObject(); + $this->assertEquals($res['namedBindings'], [ + 'bind' => ['value' => ['stringValue' => 'this']] + ]); + } + + public function testBindingTypeAutomaticDetectionPositional() + { + $query = new GqlQuery($this->mapper, 'SELECT * FROM foo', [ + 'bindings' => [ + 'this' + ] + ]); + + $res = $query->queryObject(); + $this->assertEquals($res['positionalBindings'], [ + ['value' => ['stringValue' => 'this']] + ]); + } + + public function testAllowLiterals() + { + $query = new GqlQuery($this->mapper, 'SELECT * FROM foo'); + $res = $query->queryObject(); + $this->assertFalse($res['allowLiterals']); + + $query = new GqlQuery($this->mapper, 'SELECT * FROM foo', [ + 'allowLiterals' => true + ]); + + $res = $query->queryObject(); + $this->assertTrue($res['allowLiterals']); + } + + public function testCanPaginateReturnsFalse() + { + $query = new GqlQuery($this->mapper, 'SELECT * FROM foo'); + $this->assertFalse($query->canPaginate()); + } + + public function testQueryKeyIsCorrect() + { + $query = new GqlQuery($this->mapper, 'SELECT * FROM foo'); + $this->assertEquals($query->queryKey(), 'gqlQuery'); + } + + public function testJsonSerialize() + { + $query = new GqlQuery($this->mapper, 'SELECT * FROM foo'); + $this->assertEquals($query->jsonSerialize(), $query->queryObject()); + } +} diff --git a/tests/Datastore/Query/QueryTest.php b/tests/Datastore/Query/QueryTest.php new file mode 100644 index 000000000000..5198d0050b58 --- /dev/null +++ b/tests/Datastore/Query/QueryTest.php @@ -0,0 +1,231 @@ +mapper = new EntityMapper('foo', true); + $this->query = new Query($this->mapper); + } + + public function testConstructorOptions() + { + $query = new Query($this->mapper, [ + 'query' => ['foo' => 'bar'] + ]); + + $this->assertEquals($query->queryObject(), ['foo' => 'bar']); + } + + public function testCanPaginateFlagIsTrue() + { + $this->assertTrue($this->query->canPaginate()); + } + + public function testQueryKeyIsCorrect() + { + $this->assertEquals($this->query->queryKey(), 'query'); + } + + public function testJsonSerialize() + { + + $this->assertEquals($this->query->jsonSerialize(), $this->query->queryObject()); + } + + public function testProjection() + { + $self = $this->query->projection('propname'); + $this->assertInstanceOf(Query::class, $self); + + $res = $this->query->queryObject(); + + $this->assertEquals($res['projection'], ['propname']); + } + + public function testProjectionWithArrayArgument() + { + $this->query->projection(['propname', 'propname2']); + + $res = $this->query->queryObject(); + + $this->assertEquals($res['projection'], ['propname', 'propname2']); + } + + public function testKind() + { + $self = $this->query->kind('kindName'); + $this->assertInstanceOf(Query::class, $self); + + $res = $this->query->queryObject(); + + $this->assertEquals($res['kind'], [ + ['name' => 'kindName'] + ]); + } + + public function testKindWithArrayArgument() + { + $this->query->kind(['kindName1', 'kindName2']); + + $res = $this->query->queryObject(); + + $this->assertEquals($res['kind'], [ + ['name' => 'kindName1'], + ['name' => 'kindName2'], + ]); + } + + public function testFilter() + { + $self = $this->query->filter('propname', '=', 'value'); + $this->assertInstanceOf(Query::class, $self); + + $res = $this->query->queryObject(); + + $filters = $res['filter']['compositeFilter']['filters']; + + $this->assertEquals($filters[0]['propertyFilter'], [ + 'property' => [ + 'name' => 'propname' + ], + 'value' => [ + 'stringValue' =>'value' + ], + 'op' => Query::OP_DEFAULT + ]); + } + + public function testFilterCustomOperator() + { + $self = $this->query->filter('propname', Query::OP_GREATER_THAN, 12); + $this->assertInstanceOf(Query::class, $self); + + $res = $this->query->queryObject(); + + $filters = $res['filter']['compositeFilter']['filters']; + + $this->assertEquals($filters[0]['propertyFilter'], [ + 'property' => [ + 'name' => 'propname' + ], + 'value' => [ + 'integerValue' => 12 + ], + 'op' => Query::OP_GREATER_THAN + ]); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testFilterInvalidOperator() + { + $this->query->filter('propname', 'foo', 12); + } + + public function testOrder() + { + $self = $this->query->order('propname', Query::ORDER_DESCENDING); + $this->assertInstanceOf(Query::class, $self); + + $res = $this->query->queryObject(); + + $this->assertEquals($res['order'][0], [ + 'property' => [ + 'name' => 'propname' + ], + 'direction' => Query::ORDER_DESCENDING + ]); + } + + public function testDistinctOn() + { + $self = $this->query->distinctOn('propname'); + $this->assertInstanceOf(Query::class, $self); + + $res = $this->query->queryObject(); + + $this->assertEquals($res['distinctOn'], [ + ['name' => 'propname'] + ]); + } + + public function testDistinctOnWithArrayArgument() + { + $this->query->distinctOn(['propname1', 'propname2']); + + $res = $this->query->queryObject(); + + $this->assertEquals($res['distinctOn'], [ + ['name' => 'propname1'], + ['name' => 'propname2'], + ]); + } + + public function testStart() + { + $self = $this->query->start('1234'); + $this->assertInstanceOf(Query::class, $self); + + $res = $this->query->queryObject(); + + $this->assertEquals($res['startCursor'], '1234'); + } + + public function testEnd() + { + $self = $this->query->end('1234'); + $this->assertInstanceOf(Query::class, $self); + + $res = $this->query->queryObject(); + + $this->assertEquals($res['endCursor'], '1234'); + } + + public function testOffset() + { + $self = $this->query->offset(2); + $this->assertInstanceOf(Query::class, $self); + + $res = $this->query->queryObject(); + + $this->assertEquals($res['offset'], 2); + } + + public function testLimit() + { + $self = $this->query->limit(2); + $this->assertInstanceOf(Query::class, $self); + + $res = $this->query->queryObject(); + + $this->assertEquals($res['limit'], 2); + } +} diff --git a/tests/Datastore/TransactionTest.php b/tests/Datastore/TransactionTest.php new file mode 100644 index 000000000000..c84b5dbe492a --- /dev/null +++ b/tests/Datastore/TransactionTest.php @@ -0,0 +1,234 @@ +operation = $this->prophesize(Operation::class); + $this->transaction = new TransactionStub($this->operation->reveal(), 'foo', $this->transactionId); + } + + public function testInsert() + { + $e = $this->prophesize(Entity::class); + + $this->operation->mutate(Argument::exact('insert'), Argument::type('array'), Argument::exact(Entity::class, null)) + ->shouldBeCalled()->willReturn(null); + + $this->operation->commit()->shouldNotBeCalled(); + + $this->operation->allocateIdsToEntities(Argument::type('array')) + ->willReturn([$e->reveal()]); + + $this->transaction->setOperation($this->operation->reveal()); + + $this->transaction->insert($e->reveal()); + } + + public function testInsertBatch() + { + $e = $this->prophesize(Entity::class); + + $this->operation->mutate(Argument::exact('insert'), Argument::type('array'), Argument::exact(Entity::class, null)) + ->shouldBeCalled()->willReturn(null); + + $this->operation->commit()->shouldNotBeCalled(); + + $this->operation->allocateIdsToEntities(Argument::type('array')) + ->willReturn([$e->reveal()]); + + $this->transaction->setOperation($this->operation->reveal()); + + $this->transaction->insertBatch([$e->reveal()]); + } + + public function testUpdate() + { + $e = $this->prophesize(Entity::class); + + $this->operation->mutate(Argument::exact('update'), Argument::type('array'), Argument::exact(Entity::class, null)) + ->shouldBeCalled()->willReturn(null); + + $this->operation->commit()->shouldNotBeCalled(); + + $this->operation->checkOverwrite(Argument::type('array'), Argument::exact(false))->willReturn(null); + + $this->transaction->setOperation($this->operation->reveal()); + + $this->transaction->update($e->reveal()); + } + + public function testUpdateBatch() + { + $e = $this->prophesize(Entity::class); + + $this->operation->mutate(Argument::exact('update'), Argument::type('array'), Argument::exact(Entity::class, null)) + ->shouldBeCalled()->willReturn(null); + + $this->operation->commit()->shouldNotBeCalled(); + + $this->operation->checkOverwrite(Argument::type('array'), Argument::exact(false))->willReturn(null); + + $this->transaction->setOperation($this->operation->reveal()); + + $this->transaction->updateBatch([$e->reveal()]); + } + + public function testUpsert() + { + $e = $this->prophesize(Entity::class); + + $this->operation->mutate(Argument::exact('upsert'), Argument::type('array'), Argument::exact(Entity::class, null)) + ->shouldBeCalled()->willReturn(null); + + $this->operation->commit()->shouldNotBeCalled(); + + $this->transaction->setOperation($this->operation->reveal()); + + $this->transaction->upsert($e->reveal()); + } + + public function testUpsertBatch() + { + $e = $this->prophesize(Entity::class); + + $this->operation->mutate(Argument::exact('upsert'), Argument::type('array'), Argument::exact(Entity::class, null)) + ->shouldBeCalled()->willReturn(null); + + $this->operation->commit()->shouldNotBeCalled(); + + $this->transaction->setOperation($this->operation->reveal()); + + $this->transaction->upsertBatch([$e->reveal()]); + } + + public function testDelete() + { + $k = $this->prophesize(Key::class); + + $this->operation->mutate(Argument::exact('delete'), Argument::type('array'), Argument::exact(Key::class, null)) + ->shouldBeCalled()->willReturn(null); + + $this->operation->commit()->shouldNotBeCalled(); + + + $this->transaction->setOperation($this->operation->reveal()); + + $this->transaction->delete($k->reveal()); + } + + public function testDeleteBatch() + { + $k = $this->prophesize(Key::class); + + $this->operation->mutate(Argument::exact('delete'), Argument::type('array'), Argument::exact(Key::class, null)) + ->shouldBeCalled()->willReturn(null); + + $this->operation->commit()->shouldNotBeCalled(); + + + $this->transaction->setOperation($this->operation->reveal()); + + $this->transaction->deleteBatch([$k->reveal()]); + } + + public function testLookup() + { + $this->operation->lookup(Argument::type('array'), Argument::that(function ($arg) { + if ($arg['transaction'] !== $this->transactionId) return false; + + return true; + }))->willReturn(['found' => ['foo']]); + + $this->transaction->setOperation($this->operation->reveal()); + + $k = $this->prophesize(Key::class); + + $res = $this->transaction->lookup($k->reveal()); + + $this->assertEquals($res, 'foo'); + } + + public function testLookupBatch() + { + $this->operation->lookup(Argument::type('array'), Argument::that(function ($arg) { + if ($arg['transaction'] !== $this->transactionId) return false; + + return true; + }))->willReturn([]); + + $this->transaction->setOperation($this->operation->reveal()); + + $k = $this->prophesize(Key::class); + + $this->transaction->lookupBatch([$k->reveal()]); + } + + public function testRunQuery() + { + $this->operation->runQuery(Argument::type(QueryInterface::class), Argument::that(function ($arg) { + if ($arg['transaction'] !== $this->transactionId) return false; + + return true; + }))->willReturn('test'); + + $this->transaction->setOperation($this->operation->reveal()); + + $q = $this->prophesize(QueryInterface::class); + + $res = $this->transaction->runQuery($q->reveal()); + + $this->assertEquals($res, 'test'); + } + + public function testCommit() + { + $this->operation->commit(Argument::that(function ($arg) { + if ($arg['transaction'] !== $this->transactionId) return false; + })); + + $this->transaction->setOperation($this->operation->reveal()); + + $this->transaction->commit(); + } +} + +class TransactionStub extends Transaction +{ + public function setOperation($operation) + { + $this->operation = $operation; + } +} diff --git a/tests/fixtures/datastore/entity-batch-lookup.json b/tests/fixtures/datastore/entity-batch-lookup.json new file mode 100644 index 000000000000..a26e64046bcb --- /dev/null +++ b/tests/fixtures/datastore/entity-batch-lookup.json @@ -0,0 +1,38 @@ +[ + { + "entity": { + "key": { + "path": [ + { + "kind": "Kind", + "id": 1 + } + ], + "partitionId": [] + }, + "properties": { + "Number": { + "stringValue": "First" + } + } + } + }, + { + "entity": { + "key": { + "path": [ + { + "kind": "Kind", + "id": 2 + } + ], + "partitionId": [] + }, + "properties": { + "Number": { + "stringValue": "Second" + } + } + } + } +] diff --git a/tests/fixtures/datastore/entity-result.json b/tests/fixtures/datastore/entity-result.json new file mode 100644 index 000000000000..9a7f354afc55 --- /dev/null +++ b/tests/fixtures/datastore/entity-result.json @@ -0,0 +1,22 @@ +[ + { + "entity": { + "key": { + "partitionId": { + "namespaceId": "foo", + "projectId": "bar" + }, + "path": [ + {"kind": "Kind", "name": "Name"} + ] + }, + "properties": { + "prop": { + "stringValue": "val" + } + } + }, + "version": "1234", + "cursor": "abcd" + } +] diff --git a/tests/fixtures/datastore/query-results.json b/tests/fixtures/datastore/query-results.json new file mode 100644 index 000000000000..a2fa71da0ff1 --- /dev/null +++ b/tests/fixtures/datastore/query-results.json @@ -0,0 +1,112 @@ +{ + "noResults": { + "batch": { + "entityResults": null, + "endCursor": "1234", + "moreResults": "NO_MORE_RESULTS" + } + }, + "notPaged": { + "batch": { + "entityResults": [ + { + "entity": { + "key": { + "path": [ + {"kind": "Person", "name": "John"} + ], + "partitionId": {} + }, + "properties": { + "firstName": { + "stringValue": "John" + } + } + }, + "cursor": "123" + }, { + "entity": { + "key": { + "path": [ + {"kind": "Person", "name": "Dave"} + ], + "partitionId": {} + }, + "properties": { + "firstName": { + "stringValue": "Dave" + } + } + }, + "cursor": "1234" + } + ], + "endCursor": "1234", + "moreResults": "NO_MORE_RESULTS" + } + }, + "paged": [ + { + "batch": { + "entityResults": [ + { + "entity": { + "key": { + "path": [ + {"kind": "Person", "name": "John"} + ], + "partitionId": {} + }, + "properties": { + "firstName": { + "stringValue": "John" + } + } + }, + "cursor": "123" + }, { + "entity": { + "key": { + "path": [ + {"kind": "Person", "name": "Dave"} + ], + "partitionId": {} + }, + "properties": { + "firstName": { + "stringValue": "Dave" + } + } + }, + "cursor": "1234" + } + ], + "endCursor": "1234", + "moreResults": "MORE_RESULTS_AFTER_CURSOR" + } + }, { + "batch": { + "entityResults": [ + { + "entity": { + "key": { + "path": [ + {"kind": "Person", "name": "Bob"} + ], + "partitionId": {} + }, + "properties": { + "firstName": { + "stringValue": "Bob" + } + } + }, + "cursor": "12345" + } + ], + "endCursor": "12345", + "moreResults": "NO_MORE_RESULTS" + } + } + ] +}