From 462629f460c8539b0f186bf3fc7e9d3f7e97a443 Mon Sep 17 00:00:00 2001 From: jdpedrie Date: Mon, 15 May 2017 17:57:14 -0400 Subject: [PATCH 01/11] Introduce support for Cloud Spanner (#333) --- composer.json | 3 +- dev/src/Functions.php | 29 + dev/src/Snippet/SnippetTestCase.php | 6 +- dev/src/StubTrait.php | 55 + docs/contents/cloud-spanner.json | 8 + phpunit.xml.dist | 2 + src/Core/ArrayTrait.php | 17 + src/Core/Exception/AbortedException.php | 47 + .../Exception/FailedPreconditionException.php | 27 + src/Core/Exception/ServiceException.php | 14 +- src/Core/GrpcRequestWrapper.php | 44 +- src/Core/GrpcTrait.php | 2 +- src/Core/LongRunning/LROTrait.php | 45 + .../LongRunningConnectionInterface.php | 46 + src/Core/LongRunning/LongRunningOperation.php | 377 +++++ .../LongRunning/OperationResponseTrait.php | 102 ++ src/Core/PhpArray.php | 7 +- src/Core/RequestWrapper.php | 4 + src/Core/Retry.php | 106 ++ src/Logging/Connection/Grpc.php | 20 +- src/ServiceBuilder.php | 27 + .../Admin/Database/V1/DatabaseAdminClient.php | 1049 ++++++++++++++ .../database_admin_client_config.json | 73 + .../Admin/Instance/V1/InstanceAdminClient.php | 1243 +++++++++++++++++ .../instance_admin_client_config.json | 78 ++ src/Spanner/Bytes.php | 111 ++ src/Spanner/Configuration.php | 194 +++ .../Connection/ConnectionInterface.php | 174 +++ src/Spanner/Connection/Grpc.php | 647 +++++++++ src/Spanner/Connection/IamDatabase.php | 63 + src/Spanner/Connection/IamInstance.php | 63 + .../Connection/LongRunningConnection.php | 72 + src/Spanner/Database.php | 1122 +++++++++++++++ src/Spanner/Date.php | 110 ++ src/Spanner/Duration.php | 121 ++ src/Spanner/Instance.php | 472 +++++++ src/Spanner/KeyRange.php | 237 ++++ src/Spanner/KeySet.php | 239 ++++ src/Spanner/LICENSE | 202 +++ src/Spanner/Operation.php | 382 +++++ src/Spanner/README.md | 16 + src/Spanner/Result.php | 203 +++ src/Spanner/Session/Session.php | 145 ++ src/Spanner/Session/SessionClient.php | 104 ++ src/Spanner/Session/SessionPool.php | 63 + .../Spanner/Session/SessionPoolInterface.php | 18 +- src/Spanner/Session/SimpleSessionPool.php | 54 + src/Spanner/Snapshot.php | 146 ++ src/Spanner/SpannerClient.php | 550 ++++++++ src/Spanner/Timestamp.php | 129 ++ src/Spanner/Transaction.php | 465 ++++++ src/Spanner/TransactionConfigurationTrait.php | 155 ++ src/Spanner/TransactionReadTrait.php | 108 ++ src/Spanner/V1/SpannerClient.php | 1180 ++++++++++++++++ .../V1/resources/spanner_client_config.json | 78 ++ src/Spanner/ValueInterface.php | 44 + src/Spanner/ValueMapper.php | 324 +++++ src/Spanner/composer.json | 26 + .../snippets/BigQuery/BigQueryClientTest.php | 32 +- tests/snippets/BigQuery/QueryResultsTest.php | 10 +- tests/snippets/BigQuery/TableTest.php | 39 +- tests/snippets/BigQuery/TimeTest.php | 3 +- tests/snippets/BigQuery/TimestampTest.php | 3 +- tests/snippets/Core/Iam/IamTest.php | 12 +- .../LongRunning/LongRunningOperationTest.php | 301 ++++ .../snippets/Datastore/Query/GqlQueryTest.php | 9 +- tests/snippets/Datastore/Query/QueryTest.php | 7 +- tests/snippets/Datastore/TransactionTest.php | 24 +- .../snippets/Language/LanguageClientTest.php | 14 +- tests/snippets/Logging/LoggerTest.php | 18 +- tests/snippets/Logging/LoggingClientTest.php | 14 +- tests/snippets/Logging/MetricTest.php | 14 +- tests/snippets/Logging/SinkTest.php | 16 +- tests/snippets/PubSub/PubSubClientTest.php | 14 +- tests/snippets/PubSub/SubscriptionTest.php | 30 +- tests/snippets/PubSub/TopicTest.php | 31 +- tests/snippets/ServiceBuilderTest.php | 2 + tests/snippets/Spanner/BytesTest.php | 81 ++ tests/snippets/Spanner/ConfigurationTest.php | 122 ++ tests/snippets/Spanner/DatabaseTest.php | 713 ++++++++++ tests/snippets/Spanner/DateTest.php | 78 ++ tests/snippets/Spanner/DurationTest.php | 80 ++ tests/snippets/Spanner/InstanceTest.php | 222 +++ tests/snippets/Spanner/KeyRangeTest.php | 95 ++ tests/snippets/Spanner/KeySetTest.php | 134 ++ tests/snippets/Spanner/SnapshotTest.php | 167 +++ tests/snippets/Spanner/SpannerClientTest.php | 273 ++++ tests/snippets/Spanner/TimestampTest.php | 79 ++ tests/snippets/Spanner/TransactionTest.php | 330 +++++ tests/snippets/Speech/OperationTest.php | 8 +- tests/snippets/Speech/SpeechClientTest.php | 12 +- tests/snippets/Storage/AclTest.php | 14 +- tests/snippets/Storage/BucketTest.php | 31 +- tests/snippets/Storage/StorageClientTest.php | 14 +- tests/snippets/Storage/StorageObjectTest.php | 32 +- .../Translate/TranslateClientTest.php | 16 +- tests/snippets/Vision/VisionClientTest.php | 8 +- tests/snippets/bootstrap.php | 32 - tests/system/Spanner/AdminTest.php | 102 ++ tests/system/Spanner/ConfigurationTest.php | 53 + tests/system/Spanner/OperationsTest.php | 142 ++ tests/system/Spanner/SnapshotTest.php | 57 + tests/system/Spanner/SpannerTestCase.php | 80 ++ tests/system/Spanner/TransactionTest.php | 46 + tests/system/bootstrap.php | 8 +- tests/unit/Core/ArrayTraitTest.php | 20 + tests/unit/Core/PhpArrayTest.php | 2 +- tests/unit/Datastore/OperationTest.php | 5 + tests/unit/Spanner/BytesTest.php | 52 + tests/unit/Spanner/ConfigurationTest.php | 116 ++ .../Spanner/Connection/IamDatabaseTest.php | 66 + .../Spanner/Connection/IamInstanceTest.php | 66 + .../Connection/LongRunningConnectionTest.php | 66 + tests/unit/Spanner/DatabaseTest.php | 620 ++++++++ tests/unit/Spanner/DateTest.php | 55 + tests/unit/Spanner/DurationTest.php | 65 + tests/unit/Spanner/InstanceTest.php | 318 +++++ tests/unit/Spanner/KeyRangeTest.php | 95 ++ tests/unit/Spanner/KeySetTest.php | 119 ++ tests/unit/Spanner/OperationTest.php | 303 ++++ tests/unit/Spanner/ResultTest.php | 106 ++ tests/unit/Spanner/SnapshotTest.php | 48 + tests/unit/Spanner/SpannerClientTest.php | 288 ++++ tests/unit/Spanner/TimestampTest.php | 61 + .../TransactionConfigurationTraitTest.php | 185 +++ tests/unit/Spanner/TransactionTest.php | 324 +++++ tests/unit/Spanner/ValueMapperTest.php | 385 +++++ tests/unit/fixtures/spanner/instance.json | 7 + 128 files changed, 18180 insertions(+), 262 deletions(-) create mode 100644 dev/src/Functions.php create mode 100644 dev/src/StubTrait.php create mode 100644 docs/contents/cloud-spanner.json create mode 100644 src/Core/Exception/AbortedException.php create mode 100644 src/Core/Exception/FailedPreconditionException.php create mode 100644 src/Core/LongRunning/LROTrait.php create mode 100644 src/Core/LongRunning/LongRunningConnectionInterface.php create mode 100644 src/Core/LongRunning/LongRunningOperation.php create mode 100644 src/Core/LongRunning/OperationResponseTrait.php create mode 100644 src/Core/Retry.php create mode 100644 src/Spanner/Admin/Database/V1/DatabaseAdminClient.php create mode 100644 src/Spanner/Admin/Database/V1/resources/database_admin_client_config.json create mode 100644 src/Spanner/Admin/Instance/V1/InstanceAdminClient.php create mode 100644 src/Spanner/Admin/Instance/V1/resources/instance_admin_client_config.json create mode 100644 src/Spanner/Bytes.php create mode 100644 src/Spanner/Configuration.php create mode 100644 src/Spanner/Connection/ConnectionInterface.php create mode 100644 src/Spanner/Connection/Grpc.php create mode 100644 src/Spanner/Connection/IamDatabase.php create mode 100644 src/Spanner/Connection/IamInstance.php create mode 100644 src/Spanner/Connection/LongRunningConnection.php create mode 100644 src/Spanner/Database.php create mode 100644 src/Spanner/Date.php create mode 100644 src/Spanner/Duration.php create mode 100644 src/Spanner/Instance.php create mode 100644 src/Spanner/KeyRange.php create mode 100644 src/Spanner/KeySet.php create mode 100644 src/Spanner/LICENSE create mode 100644 src/Spanner/Operation.php create mode 100644 src/Spanner/README.md create mode 100644 src/Spanner/Result.php create mode 100644 src/Spanner/Session/Session.php create mode 100644 src/Spanner/Session/SessionClient.php create mode 100644 src/Spanner/Session/SessionPool.php rename dev/src/SetStubConnectionTrait.php => src/Spanner/Session/SessionPoolInterface.php (66%) create mode 100644 src/Spanner/Session/SimpleSessionPool.php create mode 100644 src/Spanner/Snapshot.php create mode 100644 src/Spanner/SpannerClient.php create mode 100644 src/Spanner/Timestamp.php create mode 100644 src/Spanner/Transaction.php create mode 100644 src/Spanner/TransactionConfigurationTrait.php create mode 100644 src/Spanner/TransactionReadTrait.php create mode 100644 src/Spanner/V1/SpannerClient.php create mode 100644 src/Spanner/V1/resources/spanner_client_config.json create mode 100644 src/Spanner/ValueInterface.php create mode 100644 src/Spanner/ValueMapper.php create mode 100644 src/Spanner/composer.json create mode 100644 tests/snippets/Core/LongRunning/LongRunningOperationTest.php create mode 100644 tests/snippets/Spanner/BytesTest.php create mode 100644 tests/snippets/Spanner/ConfigurationTest.php create mode 100644 tests/snippets/Spanner/DatabaseTest.php create mode 100644 tests/snippets/Spanner/DateTest.php create mode 100644 tests/snippets/Spanner/DurationTest.php create mode 100644 tests/snippets/Spanner/InstanceTest.php create mode 100644 tests/snippets/Spanner/KeyRangeTest.php create mode 100644 tests/snippets/Spanner/KeySetTest.php create mode 100644 tests/snippets/Spanner/SnapshotTest.php create mode 100644 tests/snippets/Spanner/SpannerClientTest.php create mode 100644 tests/snippets/Spanner/TimestampTest.php create mode 100644 tests/snippets/Spanner/TransactionTest.php create mode 100644 tests/system/Spanner/AdminTest.php create mode 100644 tests/system/Spanner/ConfigurationTest.php create mode 100644 tests/system/Spanner/OperationsTest.php create mode 100644 tests/system/Spanner/SnapshotTest.php create mode 100644 tests/system/Spanner/SpannerTestCase.php create mode 100644 tests/system/Spanner/TransactionTest.php create mode 100644 tests/unit/Spanner/BytesTest.php create mode 100644 tests/unit/Spanner/ConfigurationTest.php create mode 100644 tests/unit/Spanner/Connection/IamDatabaseTest.php create mode 100644 tests/unit/Spanner/Connection/IamInstanceTest.php create mode 100644 tests/unit/Spanner/Connection/LongRunningConnectionTest.php create mode 100644 tests/unit/Spanner/DatabaseTest.php create mode 100644 tests/unit/Spanner/DateTest.php create mode 100644 tests/unit/Spanner/DurationTest.php create mode 100644 tests/unit/Spanner/InstanceTest.php create mode 100644 tests/unit/Spanner/KeyRangeTest.php create mode 100644 tests/unit/Spanner/KeySetTest.php create mode 100644 tests/unit/Spanner/OperationTest.php create mode 100644 tests/unit/Spanner/ResultTest.php create mode 100644 tests/unit/Spanner/SnapshotTest.php create mode 100644 tests/unit/Spanner/SpannerClientTest.php create mode 100644 tests/unit/Spanner/TimestampTest.php create mode 100644 tests/unit/Spanner/TransactionConfigurationTraitTest.php create mode 100644 tests/unit/Spanner/TransactionTest.php create mode 100644 tests/unit/Spanner/ValueMapperTest.php create mode 100644 tests/unit/fixtures/spanner/instance.json diff --git a/composer.json b/composer.json index 38de7429e585..4731d19473da 100644 --- a/composer.json +++ b/composer.json @@ -71,7 +71,8 @@ "psr-4": { "Google\\Cloud\\Dev\\": "dev/src", "Google\\Cloud\\Tests\\System\\": "tests/system" - } + }, + "files": ["dev/src/Functions.php"] }, "scripts": { "google-cloud": "dev/google-cloud" diff --git a/dev/src/Functions.php b/dev/src/Functions.php new file mode 100644 index 000000000000..fea2e9cf8d88 --- /dev/null +++ b/dev/src/Functions.php @@ -0,0 +1,29 @@ +newInstanceArgs($args); +} diff --git a/dev/src/Snippet/SnippetTestCase.php b/dev/src/Snippet/SnippetTestCase.php index 1bbf1e9ce293..b07a1deafc95 100644 --- a/dev/src/Snippet/SnippetTestCase.php +++ b/dev/src/Snippet/SnippetTestCase.php @@ -53,7 +53,7 @@ public function snippetFromClass($class, $indexOrName = 0) self::$coverage->cover($snippet->identifier()); - return $snippet; + return clone $snippet; } /** @@ -77,7 +77,7 @@ public function snippetFromMagicMethod($class, $method, $indexOrName = 0) self::$coverage->cover($identifier); - return $snippet; + return clone $snippet; } /** @@ -100,6 +100,6 @@ public function snippetFromMethod($class, $method, $indexOrName = 0) self::$coverage->cover($identifier); - return $snippet; + return clone $snippet; } } diff --git a/dev/src/StubTrait.php b/dev/src/StubTrait.php new file mode 100644 index 000000000000..7a710ed8bb72 --- /dev/null +++ b/dev/src/StubTrait.php @@ -0,0 +1,55 @@ +___getPropertyReflector($prop); + + $property->setAccessible(true); + return $property->getValue($this); + } + + public function ___setProperty($prop, $value) + { + if (!in_array($prop, json_decode($this->___props))) { + throw new \BadMethodCallException(sprintf('Property %s cannot be overloaded', $prop)); + } + + $property = $this->___getPropertyReflector($prop); + + $property->setAccessible(true); + $property->setValue($this, $value); + } + + private function ___getPropertyReflector($property) + { + $trait = new \ReflectionClass($this); + $ref = $trait->getParentClass(); + + try { + $property = $ref->getProperty($property); + } catch (\ReflectionException $e) { + throw new \BadMethodCallException($e->getMessage()); + } + + return $property; + } +} diff --git a/docs/contents/cloud-spanner.json b/docs/contents/cloud-spanner.json new file mode 100644 index 000000000000..191f68771ba9 --- /dev/null +++ b/docs/contents/cloud-spanner.json @@ -0,0 +1,8 @@ +{ + "title": "Spanner", + "pattern": "spanner\/\\w{1,}", + "nav": [{ + "title": "SpannerClient", + "type": "spanner/spannerclient" + }] +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 8afd82d909fa..23718b678755 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,6 +10,8 @@ src src/*/V[!a-zA-Z]* + src/*/*/V[!a-zA-Z]* + src/*/*/*/V[!a-zA-Z]* diff --git a/src/Core/ArrayTrait.php b/src/Core/ArrayTrait.php index 402507de987d..efca6404bf27 100644 --- a/src/Core/ArrayTrait.php +++ b/src/Core/ArrayTrait.php @@ -78,4 +78,21 @@ private function isAssoc(array $arr) { return array_keys($arr) !== range(0, count($arr) - 1); } + + /** + * Just like array_filter(), but preserves falsey values except null. + * + * @param array $arr + * @return array + */ + private function arrayFilterRemoveNull(array $arr) + { + return array_filter($arr, function ($element) { + if (!is_null($element)) { + return true; + } + + return false; + }); + } } diff --git a/src/Core/Exception/AbortedException.php b/src/Core/Exception/AbortedException.php new file mode 100644 index 000000000000..60cd19e02130 --- /dev/null +++ b/src/Core/Exception/AbortedException.php @@ -0,0 +1,47 @@ +options, function ($metadataItem) { + if (array_key_exists('retryDelay', $metadataItem)) { + return true; + } + + return false; + }); + + $delay = $metadata[0]['retryDelay']; + if (!isset($delay['seconds'])) { + $delay['seconds'] = 0; + } + + return $delay; + } +} diff --git a/src/Core/Exception/FailedPreconditionException.php b/src/Core/Exception/FailedPreconditionException.php new file mode 100644 index 000000000000..5c001c431e06 --- /dev/null +++ b/src/Core/Exception/FailedPreconditionException.php @@ -0,0 +1,27 @@ +serviceException = $serviceException; + $this->options = $options; parent::__construct($message, $code); } diff --git a/src/Core/GrpcRequestWrapper.php b/src/Core/GrpcRequestWrapper.php index f8de0d63c22f..ad42cf7d85ea 100644 --- a/src/Core/GrpcRequestWrapper.php +++ b/src/Core/GrpcRequestWrapper.php @@ -17,6 +17,7 @@ namespace Google\Cloud\Core; +use DrSlump\Protobuf\Codec\Binary; use DrSlump\Protobuf\Codec\CodecInterface; use DrSlump\Protobuf\Message; use Google\Auth\FetchAuthTokenInterface; @@ -25,6 +26,7 @@ use Google\Cloud\Core\PhpArray; use Google\Cloud\Core\RequestWrapperTrait; use Google\GAX\ApiException; +use Google\GAX\OperationResponse; use Google\GAX\PagedListResponse; use Google\GAX\RetrySettings; use Grpc; @@ -47,6 +49,11 @@ class GrpcRequestWrapper */ private $codec; + /** + * @var CodecInterface A codec used for binary deserialization. + */ + private $binaryCodec; + /** * @var array gRPC specific configuration options passed off to the GAX * library. @@ -63,6 +70,13 @@ class GrpcRequestWrapper Grpc\STATUS_DATA_LOSS ]; + /** + * @var array Map of error metadata types to RPC wrappers. + */ + private $metadataTypes = [ + 'google.rpc.retryinfo-bin' => \google\rpc\RetryInfo::class + ]; + /** * @param array $config [optional] { * Configuration options. Please see @@ -88,6 +102,7 @@ public function __construct(array $config = []) $this->authHttpHandler = $config['authHttpHandler'] ?: HttpHandlerFactory::build(); $this->codec = $config['codec']; $this->grpcOptions = $config['grpcOptions']; + $this->binaryCodec = new Binary; } /** @@ -134,7 +149,7 @@ public function send(callable $request, array $args, array $options = []) try { return $this->handleResponse($backoff->execute($request, $args)); - } catch (\Exception $ex) { + } catch (ApiException $ex) { throw $this->convertToGoogleException($ex); } } @@ -155,6 +170,10 @@ private function handleResponse($response) return $response->serialize($this->codec); } + if ($response instanceof OperationResponse) { + return $response; + } + return null; } @@ -179,6 +198,10 @@ private function convertToGoogleException(ApiException $ex) $exception = Exception\ConflictException::class; break; + case Grpc\STATUS_FAILED_PRECONDITION: + $exception = Exception\FailedPreconditionException::class; + break; + case Grpc\STATUS_UNKNOWN: $exception = Exception\ServerException::class; break; @@ -187,11 +210,28 @@ private function convertToGoogleException(ApiException $ex) $exception = Exception\ServerException::class; break; + case Grpc\STATUS_ABORTED: + $exception = Exception\AbortedException::class; + break; + default: $exception = Exception\ServiceException::class; break; } - return new $exception($ex->getMessage(), $ex->getCode(), $ex); + $metadata = []; + if ($ex->getMetadata()) { + foreach ($ex->getMetadata() as $type => $binaryValue) { + if (!isset($this->metadataTypes[$type])) { + continue; + } + + $metadata[] = (new $this->metadataTypes[$type]) + ->deserialize($binaryValue[0], $this->binaryCodec) + ->serialize($this->codec); + } + } + + return new $exception($ex->getMessage(), $ex->getCode(), $ex, $metadata); } } diff --git a/src/Core/GrpcTrait.php b/src/Core/GrpcTrait.php index fd9cfb0a8c8a..212f527c2e67 100644 --- a/src/Core/GrpcTrait.php +++ b/src/Core/GrpcTrait.php @@ -191,7 +191,7 @@ private function formatTimestampForApi($value) preg_match('/\.(\d{1,9})Z/', $value, $matches); $value = preg_replace('/\.(\d{1,9})Z/', '.000000Z', $value); $dt = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.u\Z', $value); - $nanos = (isset($matches[1])) ? str_pad($matches[1], 9, '0') : 0; + $nanos = (isset($matches[1])) ? $matches[1] : 0; return [ 'seconds' => (int)$dt->format('U'), diff --git a/src/Core/LongRunning/LROTrait.php b/src/Core/LongRunning/LROTrait.php new file mode 100644 index 000000000000..0b965357231e --- /dev/null +++ b/src/Core/LongRunning/LROTrait.php @@ -0,0 +1,45 @@ +connection = $connection; + $this->name = $name; + $this->callablesMap = $callablesMap; + } + + /** + * Return the Operation name. + * + * Example: + * ``` + * $name = $operation->name(); + * ``` + * + * @return string + */ + public function name() + { + return $this->name; + } + + /** + * Check if the Operation is done. + * + * If the Operation state is not available, a service request may be executed + * by this method. + * + * Example: + * ``` + * if ($operation->done()) { + * echo "The operation is done!"; + * } + * ``` + * + * @param array $options [optional] Configuration options. + * @return bool + */ + public function done(array $options = []) + { + return (isset($this->info($options)['done'])) + ? $this->info['done'] + : false; + } + + /** + * Get the state of the Operation. + * + * Return value will be one of `LongRunningOperation::STATE_IN_PROGRESS`, + * `LongRunningOperation::STATE_SUCCESS` or + * `LongRunningOperation::STATE_ERROR`. + * + * If the Operation state is not available, a service request may be executed + * by this method. + * + * Example: + * ``` + * switch ($operation->state()) { + * case LongRunningOperation::STATE_IN_PROGRESS: + * echo "Operation is in progress"; + * break; + * + * case LongRunningOperation::STATE_SUCCESS: + * echo "Operation succeeded"; + * break; + * + * case LongRunningOperation::STATE_ERROR: + * echo "Operation failed"; + * break; + * } + * ``` + * + * @param array $options [optional] Configuration options. + * @return string + */ + public function state(array $options = []) + { + if (!$this->done($options)) { + return self::STATE_IN_PROGRESS; + } + + if ($this->done() && $this->result()) { + return self::STATE_SUCCESS; + } + + return self::STATE_ERROR; + } + + /** + * Get the Operation result. + * + * The return type of this method is dictated by the type of Operation. + * + * Returns null if the Operation is not yet complete, or if an error occurred. + * + * If the Operation state is not available, a service request may be executed + * by this method. + * + * Example: + * ``` + * $result = $operation->result(); + * ``` + * + * @param array $options [optional] Configuration options. + * @return mixed|null + */ + public function result(array $options = []) + { + $this->info($options); + return $this->result; + } + + /** + * Get the Operation error. + * + * Returns null if the Operation is not yet complete, or if no error occurred. + * + * If the Operation state is not available, a service request may be executed + * by this method. + * + * Example: + * ``` + * $error = $operation->error(); + * ``` + * + * @param array $options [optional] Configuration options. + * @return array|null + */ + public function error(array $options = []) + { + $this->info($options); + return $this->error; + } + + /** + * Get the Operation info. + * + * If the Operation state is not available, a service request may be executed + * by this method. + * + * Example: + * ``` + * $info = $operation->info(); + * ``` + * + * @codingStandardsIgnoreStart + * @param array $options [optional] Configuration options. + * @return array [google.longrunning.Operation](https://cloud.google.com/spanner/docs/reference/rpc/google.longrunning#google.longrunning.Operation) + * @codingStandardsIgnoreEnd + */ + public function info(array $options = []) + { + return $this->info ?: $this->reload($options); + } + + /** + * Reload the Operation to check its status. + * + * Example: + * ``` + * $result = $operation->reload(); + * ``` + * + * @codingStandardsIgnoreStart + * @param array $options [optional] Configuration Options. + * @return array [google.longrunning.Operation](https://cloud.google.com/spanner/docs/reference/rpc/google.longrunning#google.longrunning.Operation) + * @codingStandardsIgnoreEnd + */ + public function reload(array $options = []) + { + $res = $this->connection->get([ + 'name' => $this->name, + ] + $options); + + $this->result = null; + $this->error = null; + if (isset($res['done']) && $res['done']) { + $type = $res['metadata']['typeUrl']; + $this->result = $this->executeDoneCallback($type, $res['response']); + $this->error = (isset($res['error'])) + ? $res['error'] + : null; + } + + return $this->info = $res; + } + + /** + * Reload the operation until it is complete. + * + * The return type of this method is dictated by the type of Operation. If + * `$options.maxPollingDurationSeconds` is set, and the poll exceeds the + * limit, the return will be `null`. + * + * Example: + * ``` + * $result = $operation->pollUntilComplete(); + * ``` + * + * @param array $options { + * Configuration Options + * + * @type float $pollingIntervalSeconds The polling interval to use, in + * seconds. **Defaults to** `1.0`. + * @type float $maxPollingDurationSeconds The maximum amount of time to + * continue polling. **Defaults to** `0.0`. + * } + * @return mixed|null + */ + public function pollUntilComplete(array $options = []) + { + $options += [ + 'pollingIntervalSeconds' => $this::WAIT_INTERVAL, + 'maxPollingDurationSeconds' => 0.0, + ]; + + $pollingIntervalMicros = $options['pollingIntervalSeconds'] * 1000000; + $maxPollingDuration = $options['maxPollingDurationSeconds']; + $hasMaxPollingDuration = $maxPollingDuration > 0.0; + $endTime = microtime(true) + $maxPollingDuration; + + do { + usleep($pollingIntervalMicros); + $this->reload($options); + } while (!$this->done() && (!$hasMaxPollingDuration || microtime(true) < $endTime)); + + return $this->result; + } + + /** + * Cancel a Long Running Operation. + * + * Example: + * ``` + * $operation->cancel(); + * ``` + * + * @param array $options Configuration options. + * @return void + */ + public function cancel(array $options = []) + { + $this->connection->cancel([ + 'name' => $this->name + ]); + } + + /** + * Delete a Long Running Operation. + * + * Example: + * ``` + * $operation->delete(); + * ``` + * + * @param array $options Configuration Options. + * @return void + */ + public function delete(array $options = []) + { + $this->connection->delete([ + 'name' => $this->name + ]); + } + + /** + * When the Operation is complete, there may be a callback enqueued to + * handle the response. If so, execute it and return the result. + * + * @param string $type The response type. + * @param mixed $response The response data. + * @return mixed + */ + private function executeDoneCallback($type, $response) + { + if (is_null($response)) { + return null; + } + + $callables = array_filter($this->callablesMap, function ($callable) use ($type) { + return $callable['typeUrl'] === $type; + }); + + if (count($callables) === 0) { + return $response; + } + + $callable = current($callables); + $fn = $callable['callable']; + + return call_user_func($fn, $response); + } + + /** + * @access private + */ + public function __debugInfo() + { + return [ + 'connection' => get_class($this->connection), + 'name' => $this->name, + 'callablesMap' => array_keys($this->callablesMap) + ]; + } +} diff --git a/src/Core/LongRunning/OperationResponseTrait.php b/src/Core/LongRunning/OperationResponseTrait.php new file mode 100644 index 000000000000..233f7a2122c7 --- /dev/null +++ b/src/Core/LongRunning/OperationResponseTrait.php @@ -0,0 +1,102 @@ +getLastProtoResponse(); + if (is_null($response)) { + return null; + } + + $response = $response->serialize($codec); + + $result = null; + if ($operation->isDone()) { + $type = $response['metadata']['typeUrl']; + $result = $this->deserializeResult($operation, $type, $codec, $lroMappers); + } + + $error = $operation->getError(); + if (!is_null($error)) { + $error = $error->serialize($codec); + } + + $response['response'] = $result; + $response['error'] = $error; + + return $response; + } + + /** + * Fetch an OperationResponse object from a gapic client. + * + * @param mixed $client A generated client with a `resumeOperation` method. + * @param string $name The Operation name. + * @return OperationResponse + */ + private function getOperationByName($client, $name) + { + return $client->resumeOperation($name); + } + + /** + * Convert an operation response to an array + * + * @param OperationResponse $operation The operation to serialize. + * @param string $type The Operation type. The type should correspond to a + * member of $mappers.typeUrl. + * @param CodecInterface $codec The gRPC codec to use for the deserialization. + * @param array $mappers A list of mappers. + * @return array|null + */ + private function deserializeResult(OperationResponse $operation, $type, CodecInterface $codec, array $mappers) + { + $mappers = array_filter($mappers, function ($mapper) use ($type) { + return $mapper['typeUrl'] === $type; + }); + + if (count($mappers) === 0) { + throw new \RuntimeException(sprintf('No mapper exists for operation response type %s.', $type)); + } + + $mapper = current($mappers); + $message = $mapper['message']; + + $response = new $message(); + $anyResponse = $operation->getLastProtoResponse()->getResponse(); + + if (is_null($anyResponse)) { + return null; + } + + $response->parse($anyResponse->getValue()); + + return $response->serialize($codec); + } +} diff --git a/src/Core/PhpArray.php b/src/Core/PhpArray.php index 4210f63bdd1b..a18ae19f0872 100644 --- a/src/Core/PhpArray.php +++ b/src/Core/PhpArray.php @@ -18,8 +18,9 @@ namespace Google\Cloud\Core; use DrSlump\Protobuf; -use google\protobuf\Struct; use google\protobuf\ListValue; +use google\protobuf\NullValue; +use google\protobuf\Struct; /** * Extend the Protobuf-PHP array codec to allow messages to match the format @@ -156,6 +157,10 @@ protected function decodeMessage(Protobuf\Message $message, $data) protected function filterValue($value, Protobuf\Field $field) { + if (trim($field->getReference(), '\\') === NullValue::class) { + return null; + } + if ($value instanceof Protobuf\Message) { if ($this->isKeyValueMessage($value)) { $v = $value->getValue(); diff --git a/src/Core/RequestWrapper.php b/src/Core/RequestWrapper.php index c8e55bfe1f4a..2418a8a1bde9 100644 --- a/src/Core/RequestWrapper.php +++ b/src/Core/RequestWrapper.php @@ -225,6 +225,10 @@ private function convertToGoogleException(\Exception $ex) $exception = Exception\ConflictException::class; break; + case 412: + $exception = Exception\FailedPreconditionException::class; + break; + case 500: $exception = Exception\ServerException::class; break; diff --git a/src/Core/Retry.php b/src/Core/Retry.php new file mode 100644 index 000000000000..ebcae9fb786e --- /dev/null +++ b/src/Core/Retry.php @@ -0,0 +1,106 @@ + (int >= 0), 'nanos' => (int >= 0)] specifying how + * long an operation should pause before retrying. Should accept a + * single argument of type `\Exception`. + * @param callable $retryFunction [optional] returns bool for whether or not + * to retry. + */ + public function __construct( + $retries, + callable $delayFunction, + callable $retryFunction = null + ) { + $this->retries = $retries !== null ? (int) $retries : 3; + $this->retryFunction = $retryFunction; + $this->delayFunction = $delayFunction; + } + + /** + * Executes the retry process. + * + * @param callable $function + * @param array $arguments [optional] + * @return mixed + * @throws \Exception The last exception caught while retrying. + */ + public function execute(callable $function, array $arguments = []) + { + $delayFunction = $this->delayFunction; + $retryAttempt = 0; + $exception = null; + + while (true) { + try { + return call_user_func_array($function, $arguments); + } catch (\Exception $exception) { + if ($this->retryFunction) { + if (!call_user_func($this->retryFunction, $exception)) { + throw $exception; + } + } + + if ($retryAttempt >= $this->retries) { + break; + } + + $delayFunction($exception); + $retryAttempt++; + } + } + + throw $exception; + } + + /** + * @param callable $delayFunction + * @return void + */ + public function setDelayFunction(callable $delayFunction) + { + $this->delayFunction = $delayFunction; + } +} diff --git a/src/Logging/Connection/Grpc.php b/src/Logging/Connection/Grpc.php index b5f00f8aeb94..cfa006bbb0f4 100644 --- a/src/Logging/Connection/Grpc.php +++ b/src/Logging/Connection/Grpc.php @@ -90,15 +90,17 @@ class Grpc implements ConnectionInterface public function __construct(array $config = []) { $this->codec = new PhpArray([ - 'timestamp' => function ($v) { - return $this->formatTimestampFromApi($v); - }, - 'severity' => function ($v) { - return Logger::getLogLevelMap()[$v]; - }, - 'outputVersionFormat' => function ($v) { - return self::$versionFormatMap[$v]; - } + 'customFilters' => [ + 'timestamp' => function ($v) { + return $this->formatTimestampFromApi($v); + }, + 'severity' => function ($v) { + return Logger::getLogLevelMap()[$v]; + }, + 'outputVersionFormat' => function ($v) { + return self::$versionFormatMap[$v]; + } + ] ]); $config['codec'] = $this->codec; $this->setRequestWrapper(new GrpcRequestWrapper($config)); diff --git a/src/ServiceBuilder.php b/src/ServiceBuilder.php index bcbed5c9f11d..ad0541e685c4 100644 --- a/src/ServiceBuilder.php +++ b/src/ServiceBuilder.php @@ -23,6 +23,7 @@ use Google\Cloud\Logging\LoggingClient; use Google\Cloud\Language\LanguageClient; use Google\Cloud\PubSub\PubSubClient; +use Google\Cloud\Spanner\SpannerClient; use Google\Cloud\Speech\SpeechClient; use Google\Cloud\Storage\StorageClient; use Google\Cloud\Trace\TraceClient; @@ -114,6 +115,7 @@ public function __construct(array $config = []) * @type bool $returnInt64AsObject If true, 64 bit integers will be * returned as a {@see Google\Cloud\Core\Int64} object for 32 bit * platform compatibility. **Defaults to** false. + * } * @return BigQueryClient */ public function bigQuery(array $config = []) @@ -210,6 +212,31 @@ public function pubsub(array $config = []) return new PubSubClient($config ? $this->resolveConfig($config) : $this->config); } + /** + * Google Cloud Spanner is a highly scalable, transactional, managed, NewSQL + * database service. Find more information at + * [Google Cloud Spanner API docs](https://cloud.google.com/spanner/). + * + * Example: + * ``` + * $spanner = $cloud->spanner(); + * ``` + * + * @param array $config [optional] { + * Configuration options. See + * {@see Google\Cloud\ServiceBuilder::__construct()} for the other available options. + * + * @type bool $returnInt64AsObject If true, 64 bit integers will be + * returned as a {@see Google\Cloud\Int64} object for 32 bit + * platform compatibility. **Defaults to** false. + * } + * @return SpannerClient + */ + public function spanner(array $config = []) + { + return new SpannerClient($config ? $this->resolveConfig($config) : $this->config); + } + /** * Google Cloud Speech enables easy integration of Google speech recognition * technologies into developer applications. Send audio and receive a text diff --git a/src/Spanner/Admin/Database/V1/DatabaseAdminClient.php b/src/Spanner/Admin/Database/V1/DatabaseAdminClient.php new file mode 100644 index 000000000000..332d10cc7728 --- /dev/null +++ b/src/Spanner/Admin/Database/V1/DatabaseAdminClient.php @@ -0,0 +1,1049 @@ +listDatabases($formattedParent); + * foreach ($pagedResponse->iterateAllElements() as $element) { + * // doSomethingWith($element); + * } + * + * // OR iterate over pages of elements, with the maximum page size set to 5 + * $pagedResponse = $databaseAdminClient->listDatabases($formattedParent, ['pageSize' => 5]); + * foreach ($pagedResponse->iteratePages() as $page) { + * foreach ($page as $element) { + * // doSomethingWith($element); + * } + * } + * } finally { + * $databaseAdminClient->close(); + * } + * ``` + * + * Many parameters require resource names to be formatted in a particular way. To assist + * with these names, this class includes a format method for each type of name, and additionally + * a parse method to extract the individual identifiers contained within names that are + * returned. + */ +class DatabaseAdminClient +{ + /** + * The default address of the service. + */ + const SERVICE_ADDRESS = 'spanner.googleapis.com'; + + /** + * The default port of the service. + */ + const DEFAULT_SERVICE_PORT = 443; + + /** + * The default timeout for non-retrying methods. + */ + const DEFAULT_TIMEOUT_MILLIS = 30000; + + /** + * The name of the code generator, to be included in the agent header. + */ + const CODEGEN_NAME = 'gapic'; + + /** + * The code generator version, to be included in the agent header. + */ + const CODEGEN_VERSION = '0.1.0'; + + private static $instanceNameTemplate; + private static $databaseNameTemplate; + + private $grpcCredentialsHelper; + private $databaseAdminStub; + private $scopes; + private $defaultCallSettings; + private $descriptors; + private $operationsClient; + + /** + * Formats a string containing the fully-qualified path to represent + * a instance resource. + */ + public static function formatInstanceName($project, $instance) + { + return self::getInstanceNameTemplate()->render([ + 'project' => $project, + 'instance' => $instance, + ]); + } + + /** + * Formats a string containing the fully-qualified path to represent + * a database resource. + */ + public static function formatDatabaseName($project, $instance, $database) + { + return self::getDatabaseNameTemplate()->render([ + 'project' => $project, + 'instance' => $instance, + 'database' => $database, + ]); + } + + /** + * Parses the project from the given fully-qualified path which + * represents a instance resource. + */ + public static function parseProjectFromInstanceName($instanceName) + { + return self::getInstanceNameTemplate()->match($instanceName)['project']; + } + + /** + * Parses the instance from the given fully-qualified path which + * represents a instance resource. + */ + public static function parseInstanceFromInstanceName($instanceName) + { + return self::getInstanceNameTemplate()->match($instanceName)['instance']; + } + + /** + * Parses the project from the given fully-qualified path which + * represents a database resource. + */ + public static function parseProjectFromDatabaseName($databaseName) + { + return self::getDatabaseNameTemplate()->match($databaseName)['project']; + } + + /** + * Parses the instance from the given fully-qualified path which + * represents a database resource. + */ + public static function parseInstanceFromDatabaseName($databaseName) + { + return self::getDatabaseNameTemplate()->match($databaseName)['instance']; + } + + /** + * Parses the database from the given fully-qualified path which + * represents a database resource. + */ + public static function parseDatabaseFromDatabaseName($databaseName) + { + return self::getDatabaseNameTemplate()->match($databaseName)['database']; + } + + private static function getInstanceNameTemplate() + { + if (self::$instanceNameTemplate == null) { + self::$instanceNameTemplate = new PathTemplate('projects/{project}/instances/{instance}'); + } + + return self::$instanceNameTemplate; + } + + private static function getDatabaseNameTemplate() + { + if (self::$databaseNameTemplate == null) { + self::$databaseNameTemplate = new PathTemplate('projects/{project}/instances/{instance}/databases/{database}'); + } + + return self::$databaseNameTemplate; + } + + private static function getPageStreamingDescriptors() + { + $listDatabasesPageStreamingDescriptor = + new PageStreamingDescriptor([ + 'requestPageTokenField' => 'page_token', + 'requestPageSizeField' => 'page_size', + 'responsePageTokenField' => 'next_page_token', + 'resourceField' => 'databases', + ]); + + $pageStreamingDescriptors = [ + 'listDatabases' => $listDatabasesPageStreamingDescriptor, + ]; + + return $pageStreamingDescriptors; + } + + private static function getLongRunningDescriptors() + { + return [ + 'createDatabase' => [ + 'operationReturnType' => '\google\spanner\admin\database\v1\Database', + 'metadataReturnType' => '\google\spanner\admin\database\v1\CreateDatabaseMetadata', + ], + 'updateDatabaseDdl' => [ + 'operationReturnType' => '\google\protobuf\EmptyC', + 'metadataReturnType' => '\google\spanner\admin\database\v1\UpdateDatabaseDdlMetadata', + ], + ]; + } + + /** + * Return an OperationsClient object with the same endpoint as $this. + * + * @return \Google\GAX\LongRunning\OperationsClient + */ + public function getOperationsClient() + { + return $this->operationsClient; + } + + /** + * Resume an existing long running operation that was previously started + * by a long running API method. If $methodName is not provided, or does + * not match a long running API method, then the operation can still be + * resumed, but the OperationResponse object will not deserialize the + * final response. + * + * @param string $operationName The name of the long running operation + * @param string $methodName The name of the method used to start the operation + * + * @return \Google\GAX\OperationResponse + */ + public function resumeOperation($operationName, $methodName = null) + { + $lroDescriptors = self::getLongRunningDescriptors(); + if (!is_null($methodName) && array_key_exists($methodName, $lroDescriptors)) { + $options = $lroDescriptors[$methodName]; + } else { + $options = []; + } + $operation = new OperationResponse($operationName, $this->getOperationsClient(), $options); + $operation->reload(); + + return $operation; + } + + // TODO(garrettjones): add channel (when supported in gRPC) + /** + * Constructor. + * + * @param array $options { + * Optional. Options for configuring the service API wrapper. + * + * @type string $serviceAddress The domain name of the API remote host. + * Default 'spanner.googleapis.com'. + * @type mixed $port The port on which to connect to the remote host. Default 443. + * @type \Grpc\ChannelCredentials $sslCreds + * A `ChannelCredentials` for use with an SSL-enabled channel. + * Default: a credentials object returned from + * \Grpc\ChannelCredentials::createSsl() + * @type array $scopes A string array of scopes to use when acquiring credentials. + * Default the scopes for the Google Cloud Spanner Database Admin API. + * @type array $retryingOverride + * An associative array of string => RetryOptions, where the keys + * are method names (e.g. 'createFoo'), that overrides default retrying + * settings. A value of null indicates that the method in question should + * not retry. + * @type int $timeoutMillis The timeout in milliseconds to use for calls + * that don't use retries. For calls that use retries, + * set the timeout in RetryOptions. + * Default: 30000 (30 seconds) + * @type string $appName The codename of the calling service. Default 'gax'. + * @type string $appVersion The version of the calling service. + * Default: the current version of GAX. + * @type \Google\Auth\CredentialsLoader $credentialsLoader + * A CredentialsLoader object created using the + * Google\Auth library. + * } + */ + public function __construct($options = []) + { + $defaultOptions = [ + 'serviceAddress' => self::SERVICE_ADDRESS, + 'port' => self::DEFAULT_SERVICE_PORT, + 'scopes' => [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/spanner.admin', + ], + 'retryingOverride' => null, + 'timeoutMillis' => self::DEFAULT_TIMEOUT_MILLIS, + 'appName' => 'gax', + 'appVersion' => AgentHeaderDescriptor::getGaxVersion(), + ]; + $options = array_merge($defaultOptions, $options); + + if (array_key_exists('operationsClient', $options)) { + $this->operationsClient = $options['operationsClient']; + } else { + $this->operationsClient = new OperationsClient([ + 'serviceAddress' => $options['serviceAddress'], + 'scopes' => $options['scopes'], + ]); + } + + $headerDescriptor = new AgentHeaderDescriptor([ + 'clientName' => $options['appName'], + 'clientVersion' => $options['appVersion'], + 'codeGenName' => self::CODEGEN_NAME, + 'codeGenVersion' => self::CODEGEN_VERSION, + 'gaxVersion' => AgentHeaderDescriptor::getGaxVersion(), + 'phpVersion' => phpversion(), + ]); + + $defaultDescriptors = ['headerDescriptor' => $headerDescriptor]; + $this->descriptors = [ + 'listDatabases' => $defaultDescriptors, + 'createDatabase' => $defaultDescriptors, + 'getDatabase' => $defaultDescriptors, + 'updateDatabaseDdl' => $defaultDescriptors, + 'dropDatabase' => $defaultDescriptors, + 'getDatabaseDdl' => $defaultDescriptors, + 'setIamPolicy' => $defaultDescriptors, + 'getIamPolicy' => $defaultDescriptors, + 'testIamPermissions' => $defaultDescriptors, + ]; + $pageStreamingDescriptors = self::getPageStreamingDescriptors(); + foreach ($pageStreamingDescriptors as $method => $pageStreamingDescriptor) { + $this->descriptors[$method]['pageStreamingDescriptor'] = $pageStreamingDescriptor; + } + $longRunningDescriptors = self::getLongRunningDescriptors(); + foreach ($longRunningDescriptors as $method => $longRunningDescriptor) { + $this->descriptors[$method]['longRunningDescriptor'] = $longRunningDescriptor + ['operationsClient' => $this->operationsClient]; + } + + $clientConfigJsonString = file_get_contents(__DIR__.'/resources/database_admin_client_config.json'); + $clientConfig = json_decode($clientConfigJsonString, true); + $this->defaultCallSettings = + CallSettings::load( + 'google.spanner.admin.database.v1.DatabaseAdmin', + $clientConfig, + $options['retryingOverride'], + GrpcConstants::getStatusCodeNames(), + $options['timeoutMillis'] + ); + + $this->scopes = $options['scopes']; + + $createStubOptions = []; + if (array_key_exists('sslCreds', $options)) { + $createStubOptions['sslCreds'] = $options['sslCreds']; + } + $grpcCredentialsHelperOptions = array_diff_key($options, $defaultOptions); + $this->grpcCredentialsHelper = new GrpcCredentialsHelper($this->scopes, $grpcCredentialsHelperOptions); + + $createDatabaseAdminStubFunction = function ($hostname, $opts) { + return new DatabaseAdminGrpcClient($hostname, $opts); + }; + if (array_key_exists('createDatabaseAdminStubFunction', $options)) { + $createDatabaseAdminStubFunction = $options['createDatabaseAdminStubFunction']; + } + $this->databaseAdminStub = $this->grpcCredentialsHelper->createStub( + $createDatabaseAdminStubFunction, + $options['serviceAddress'], + $options['port'], + $createStubOptions + ); + } + + /** + * Lists Cloud Spanner databases. + * + * Sample code: + * ``` + * try { + * $databaseAdminClient = new DatabaseAdminClient(); + * $formattedParent = DatabaseAdminClient::formatInstanceName("[PROJECT]", "[INSTANCE]"); + * // Iterate through all elements + * $pagedResponse = $databaseAdminClient->listDatabases($formattedParent); + * foreach ($pagedResponse->iterateAllElements() as $element) { + * // doSomethingWith($element); + * } + * + * // OR iterate over pages of elements, with the maximum page size set to 5 + * $pagedResponse = $databaseAdminClient->listDatabases($formattedParent, ['pageSize' => 5]); + * foreach ($pagedResponse->iteratePages() as $page) { + * foreach ($page as $element) { + * // doSomethingWith($element); + * } + * } + * } finally { + * $databaseAdminClient->close(); + * } + * ``` + * + * @param string $parent Required. The instance whose databases should be listed. + * Values are of the form `projects//instances/`. + * @param array $optionalArgs { + * Optional. + * + * @type int $pageSize + * The maximum number of resources contained in the underlying API + * response. The API may return fewer values in a page, even if + * there are additional values to be retrieved. + * @type string $pageToken + * A page token is used to specify a page of values to be returned. + * If no page token is specified (the default), the first page + * of values will be returned. Any page token used here must have + * been generated by a previous call to the API. + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \Google\GAX\PagedListResponse + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function listDatabases($parent, $optionalArgs = []) + { + $request = new ListDatabasesRequest(); + $request->setParent($parent); + if (isset($optionalArgs['pageSize'])) { + $request->setPageSize($optionalArgs['pageSize']); + } + if (isset($optionalArgs['pageToken'])) { + $request->setPageToken($optionalArgs['pageToken']); + } + + $mergedSettings = $this->defaultCallSettings['listDatabases']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->databaseAdminStub, + 'ListDatabases', + $mergedSettings, + $this->descriptors['listDatabases'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Creates a new Cloud Spanner database and starts to prepare it for serving. + * The returned [long-running operation][google.longrunning.Operation] will + * have a name of the format `/operations/` and + * can be used to track preparation of the database. The + * [metadata][google.longrunning.Operation.metadata] field type is + * [CreateDatabaseMetadata][google.spanner.admin.database.v1.CreateDatabaseMetadata]. The + * [response][google.longrunning.Operation.response] field type is + * [Database][google.spanner.admin.database.v1.Database], if successful. + * + * Sample code: + * ``` + * try { + * $databaseAdminClient = new DatabaseAdminClient(); + * $formattedParent = DatabaseAdminClient::formatInstanceName("[PROJECT]", "[INSTANCE]"); + * $createStatement = ""; + * $operationResponse = $databaseAdminClient->createDatabase($formattedParent, $createStatement); + * $operationResponse->pollUntilComplete(); + * if ($operationResponse->operationSucceeded()) { + * $result = $operationResponse->getResult(); + * // doSomethingWith($result) + * } else { + * $error = $operationResponse->getError(); + * // handleError($error) + * } + * + * // OR start the operation, keep the operation name, and resume later + * $operationResponse = $databaseAdminClient->createDatabase($formattedParent, $createStatement); + * $operationName = $operationResponse->getName(); + * // ... do other work + * $newOperationResponse = $databaseAdminClient->resumeOperation($operationName, 'createDatabase'); + * while (!$newOperationResponse->isDone()) { + * // ... do other work + * $newOperationResponse->reload(); + * } + * if ($newOperationResponse->operationSucceeded()) { + * $result = $newOperationResponse->getResult(); + * // doSomethingWith($result) + * } else { + * $error = $newOperationResponse->getError(); + * // handleError($error) + * } + * } finally { + * $databaseAdminClient->close(); + * } + * ``` + * + * @param string $parent Required. The name of the instance that will serve the new database. + * Values are of the form `projects//instances/`. + * @param string $createStatement Required. A `CREATE DATABASE` statement, which specifies the ID of the + * new database. The database ID must conform to the regular expression + * `[a-z][a-z0-9_\-]*[a-z0-9]` and be between 2 and 30 characters in length. + * @param array $optionalArgs { + * Optional. + * + * @type string[] $extraStatements + * An optional list of DDL statements to run inside the newly created + * database. Statements can create tables, indexes, etc. These + * statements execute atomically with the creation of the database: + * if there is an error in any statement, the database is not created. + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\longrunning\Operation + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function createDatabase($parent, $createStatement, $optionalArgs = []) + { + $request = new CreateDatabaseRequest(); + $request->setParent($parent); + $request->setCreateStatement($createStatement); + if (isset($optionalArgs['extraStatements'])) { + foreach ($optionalArgs['extraStatements'] as $elem) { + $request->addExtraStatements($elem); + } + } + + $mergedSettings = $this->defaultCallSettings['createDatabase']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->databaseAdminStub, + 'CreateDatabase', + $mergedSettings, + $this->descriptors['createDatabase'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Gets the state of a Cloud Spanner database. + * + * Sample code: + * ``` + * try { + * $databaseAdminClient = new DatabaseAdminClient(); + * $formattedName = DatabaseAdminClient::formatDatabaseName("[PROJECT]", "[INSTANCE]", "[DATABASE]"); + * $response = $databaseAdminClient->getDatabase($formattedName); + * } finally { + * $databaseAdminClient->close(); + * } + * ``` + * + * @param string $name Required. The name of the requested database. Values are of the form + * `projects//instances//databases/`. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\spanner\admin\database\v1\Database + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function getDatabase($name, $optionalArgs = []) + { + $request = new GetDatabaseRequest(); + $request->setName($name); + + $mergedSettings = $this->defaultCallSettings['getDatabase']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->databaseAdminStub, + 'GetDatabase', + $mergedSettings, + $this->descriptors['getDatabase'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Updates the schema of a Cloud Spanner database by + * creating/altering/dropping tables, columns, indexes, etc. The returned + * [long-running operation][google.longrunning.Operation] will have a name of + * the format `/operations/` and can be used to + * track execution of the schema change(s). The + * [metadata][google.longrunning.Operation.metadata] field type is + * [UpdateDatabaseDdlMetadata][google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata]. The operation has no response. + * + * Sample code: + * ``` + * try { + * $databaseAdminClient = new DatabaseAdminClient(); + * $formattedDatabase = DatabaseAdminClient::formatDatabaseName("[PROJECT]", "[INSTANCE]", "[DATABASE]"); + * $statements = []; + * $operationResponse = $databaseAdminClient->updateDatabaseDdl($formattedDatabase, $statements); + * $operationResponse->pollUntilComplete(); + * if ($operationResponse->operationSucceeded()) { + * // operation succeeded and returns no value + * } else { + * $error = $operationResponse->getError(); + * // handleError($error) + * } + * + * // OR start the operation, keep the operation name, and resume later + * $operationResponse = $databaseAdminClient->updateDatabaseDdl($formattedDatabase, $statements); + * $operationName = $operationResponse->getName(); + * // ... do other work + * $newOperationResponse = $databaseAdminClient->resumeOperation($operationName, 'updateDatabaseDdl'); + * while (!$newOperationResponse->isDone()) { + * // ... do other work + * $newOperationResponse->reload(); + * } + * if ($newOperationResponse->operationSucceeded()) { + * // operation succeeded and returns no value + * } else { + * $error = $newOperationResponse->getError(); + * // handleError($error) + * } + * } finally { + * $databaseAdminClient->close(); + * } + * ``` + * + * @param string $database Required. The database to update. + * @param string[] $statements DDL statements to be applied to the database. + * @param array $optionalArgs { + * Optional. + * + * @type string $operationId + * If empty, the new update request is assigned an + * automatically-generated operation ID. Otherwise, `operation_id` + * is used to construct the name of the resulting + * [Operation][google.longrunning.Operation]. + * + * Specifying an explicit operation ID simplifies determining + * whether the statements were executed in the event that the + * [UpdateDatabaseDdl][google.spanner.admin.database.v1.DatabaseAdmin.UpdateDatabaseDdl] call is replayed, + * or the return value is otherwise lost: the [database][google.spanner.admin.database.v1.UpdateDatabaseDdlRequest.database] and + * `operation_id` fields can be combined to form the + * [name][google.longrunning.Operation.name] of the resulting + * [longrunning.Operation][google.longrunning.Operation]: `/operations/`. + * + * `operation_id` should be unique within the database, and must be + * a valid identifier: `[a-zA-Z][a-zA-Z0-9_]*`. Note that + * automatically-generated operation IDs always begin with an + * underscore. If the named operation already exists, + * [UpdateDatabaseDdl][google.spanner.admin.database.v1.DatabaseAdmin.UpdateDatabaseDdl] returns + * `ALREADY_EXISTS`. + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\longrunning\Operation + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function updateDatabaseDdl($database, $statements, $optionalArgs = []) + { + $request = new UpdateDatabaseDdlRequest(); + $request->setDatabase($database); + foreach ($statements as $elem) { + $request->addStatements($elem); + } + if (isset($optionalArgs['operationId'])) { + $request->setOperationId($optionalArgs['operationId']); + } + + $mergedSettings = $this->defaultCallSettings['updateDatabaseDdl']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->databaseAdminStub, + 'UpdateDatabaseDdl', + $mergedSettings, + $this->descriptors['updateDatabaseDdl'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Drops (aka deletes) a Cloud Spanner database. + * + * Sample code: + * ``` + * try { + * $databaseAdminClient = new DatabaseAdminClient(); + * $formattedDatabase = DatabaseAdminClient::formatDatabaseName("[PROJECT]", "[INSTANCE]", "[DATABASE]"); + * $databaseAdminClient->dropDatabase($formattedDatabase); + * } finally { + * $databaseAdminClient->close(); + * } + * ``` + * + * @param string $database Required. The database to be dropped. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function dropDatabase($database, $optionalArgs = []) + { + $request = new DropDatabaseRequest(); + $request->setDatabase($database); + + $mergedSettings = $this->defaultCallSettings['dropDatabase']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->databaseAdminStub, + 'DropDatabase', + $mergedSettings, + $this->descriptors['dropDatabase'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Returns the schema of a Cloud Spanner database as a list of formatted + * DDL statements. This method does not show pending schema updates, those may + * be queried using the [Operations][google.longrunning.Operations] API. + * + * Sample code: + * ``` + * try { + * $databaseAdminClient = new DatabaseAdminClient(); + * $formattedDatabase = DatabaseAdminClient::formatDatabaseName("[PROJECT]", "[INSTANCE]", "[DATABASE]"); + * $response = $databaseAdminClient->getDatabaseDdl($formattedDatabase); + * } finally { + * $databaseAdminClient->close(); + * } + * ``` + * + * @param string $database Required. The database whose schema we wish to get. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\spanner\admin\database\v1\GetDatabaseDdlResponse + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function getDatabaseDdl($database, $optionalArgs = []) + { + $request = new GetDatabaseDdlRequest(); + $request->setDatabase($database); + + $mergedSettings = $this->defaultCallSettings['getDatabaseDdl']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->databaseAdminStub, + 'GetDatabaseDdl', + $mergedSettings, + $this->descriptors['getDatabaseDdl'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Sets the access control policy on a database resource. Replaces any + * existing policy. + * + * Authorization requires `spanner.databases.setIamPolicy` permission on + * [resource][google.iam.v1.SetIamPolicyRequest.resource]. + * + * Sample code: + * ``` + * try { + * $databaseAdminClient = new DatabaseAdminClient(); + * $formattedResource = DatabaseAdminClient::formatDatabaseName("[PROJECT]", "[INSTANCE]", "[DATABASE]"); + * $policy = new Policy(); + * $response = $databaseAdminClient->setIamPolicy($formattedResource, $policy); + * } finally { + * $databaseAdminClient->close(); + * } + * ``` + * + * @param string $resource REQUIRED: The resource for which the policy is being specified. + * `resource` is usually specified as a path. For example, a Project + * resource is specified as `projects/{project}`. + * @param Policy $policy REQUIRED: The complete policy to be applied to the `resource`. The size of + * the policy is limited to a few 10s of KB. An empty policy is a + * valid policy but certain Cloud Platform services (such as Projects) + * might reject them. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\iam\v1\Policy + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function setIamPolicy($resource, $policy, $optionalArgs = []) + { + $request = new SetIamPolicyRequest(); + $request->setResource($resource); + $request->setPolicy($policy); + + $mergedSettings = $this->defaultCallSettings['setIamPolicy']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->databaseAdminStub, + 'SetIamPolicy', + $mergedSettings, + $this->descriptors['setIamPolicy'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Gets the access control policy for a database resource. Returns an empty + * policy if a database exists but does not have a policy set. + * + * Authorization requires `spanner.databases.getIamPolicy` permission on + * [resource][google.iam.v1.GetIamPolicyRequest.resource]. + * + * Sample code: + * ``` + * try { + * $databaseAdminClient = new DatabaseAdminClient(); + * $formattedResource = DatabaseAdminClient::formatDatabaseName("[PROJECT]", "[INSTANCE]", "[DATABASE]"); + * $response = $databaseAdminClient->getIamPolicy($formattedResource); + * } finally { + * $databaseAdminClient->close(); + * } + * ``` + * + * @param string $resource REQUIRED: The resource for which the policy is being requested. + * `resource` is usually specified as a path. For example, a Project + * resource is specified as `projects/{project}`. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\iam\v1\Policy + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function getIamPolicy($resource, $optionalArgs = []) + { + $request = new GetIamPolicyRequest(); + $request->setResource($resource); + + $mergedSettings = $this->defaultCallSettings['getIamPolicy']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->databaseAdminStub, + 'GetIamPolicy', + $mergedSettings, + $this->descriptors['getIamPolicy'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Returns permissions that the caller has on the specified database resource. + * + * Attempting this RPC on a non-existent Cloud Spanner database will result in + * a NOT_FOUND error if the user has `spanner.databases.list` permission on + * the containing Cloud Spanner instance. Otherwise returns an empty set of + * permissions. + * + * Sample code: + * ``` + * try { + * $databaseAdminClient = new DatabaseAdminClient(); + * $formattedResource = DatabaseAdminClient::formatDatabaseName("[PROJECT]", "[INSTANCE]", "[DATABASE]"); + * $permissions = []; + * $response = $databaseAdminClient->testIamPermissions($formattedResource, $permissions); + * } finally { + * $databaseAdminClient->close(); + * } + * ``` + * + * @param string $resource REQUIRED: The resource for which the policy detail is being requested. + * `resource` is usually specified as a path. For example, a Project + * resource is specified as `projects/{project}`. + * @param string[] $permissions The set of permissions to check for the `resource`. Permissions with + * wildcards (such as '*' or 'storage.*') are not allowed. For more + * information see + * [IAM Overview](https://cloud.google.com/iam/docs/overview#permissions). + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\iam\v1\TestIamPermissionsResponse + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function testIamPermissions($resource, $permissions, $optionalArgs = []) + { + $request = new TestIamPermissionsRequest(); + $request->setResource($resource); + foreach ($permissions as $elem) { + $request->addPermissions($elem); + } + + $mergedSettings = $this->defaultCallSettings['testIamPermissions']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->databaseAdminStub, + 'TestIamPermissions', + $mergedSettings, + $this->descriptors['testIamPermissions'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Initiates an orderly shutdown in which preexisting calls continue but new + * calls are immediately cancelled. + */ + public function close() + { + $this->databaseAdminStub->close(); + } + + private function createCredentialsCallback() + { + return $this->grpcCredentialsHelper->createCallCredentialsCallback(); + } +} diff --git a/src/Spanner/Admin/Database/V1/resources/database_admin_client_config.json b/src/Spanner/Admin/Database/V1/resources/database_admin_client_config.json new file mode 100644 index 000000000000..efa919a0a7d8 --- /dev/null +++ b/src/Spanner/Admin/Database/V1/resources/database_admin_client_config.json @@ -0,0 +1,73 @@ +{ + "interfaces": { + "google.spanner.admin.database.v1.DatabaseAdmin": { + "retry_codes": { + "retry_codes_def": { + "idempotent": [ + "DEADLINE_EXCEEDED", + "UNAVAILABLE" + ], + "non_idempotent": [] + } + }, + "retry_params": { + "default": { + "initial_retry_delay_millis": 1000, + "retry_delay_multiplier": 1.3, + "max_retry_delay_millis": 32000, + "initial_rpc_timeout_millis": 60000, + "rpc_timeout_multiplier": 1.0, + "max_rpc_timeout_millis": 60000, + "total_timeout_millis": 600000 + } + }, + "methods": { + "ListDatabases": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "CreateDatabase": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + }, + "GetDatabase": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "UpdateDatabaseDdl": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "DropDatabase": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "GetDatabaseDdl": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "SetIamPolicy": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + }, + "GetIamPolicy": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "TestIamPermissions": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + } + } + } + } +} diff --git a/src/Spanner/Admin/Instance/V1/InstanceAdminClient.php b/src/Spanner/Admin/Instance/V1/InstanceAdminClient.php new file mode 100644 index 000000000000..bb7d0a10b257 --- /dev/null +++ b/src/Spanner/Admin/Instance/V1/InstanceAdminClient.php @@ -0,0 +1,1243 @@ +listInstanceConfigs($formattedParent); + * foreach ($pagedResponse->iterateAllElements() as $element) { + * // doSomethingWith($element); + * } + * + * // OR iterate over pages of elements, with the maximum page size set to 5 + * $pagedResponse = $instanceAdminClient->listInstanceConfigs($formattedParent, ['pageSize' => 5]); + * foreach ($pagedResponse->iteratePages() as $page) { + * foreach ($page as $element) { + * // doSomethingWith($element); + * } + * } + * } finally { + * $instanceAdminClient->close(); + * } + * ``` + * + * Many parameters require resource names to be formatted in a particular way. To assist + * with these names, this class includes a format method for each type of name, and additionally + * a parse method to extract the individual identifiers contained within names that are + * returned. + */ +class InstanceAdminClient +{ + /** + * The default address of the service. + */ + const SERVICE_ADDRESS = 'spanner.googleapis.com'; + + /** + * The default port of the service. + */ + const DEFAULT_SERVICE_PORT = 443; + + /** + * The default timeout for non-retrying methods. + */ + const DEFAULT_TIMEOUT_MILLIS = 30000; + + /** + * The name of the code generator, to be included in the agent header. + */ + const CODEGEN_NAME = 'gapic'; + + /** + * The code generator version, to be included in the agent header. + */ + const CODEGEN_VERSION = '0.1.0'; + + private static $projectNameTemplate; + private static $instanceConfigNameTemplate; + private static $instanceNameTemplate; + + private $grpcCredentialsHelper; + private $instanceAdminStub; + private $scopes; + private $defaultCallSettings; + private $descriptors; + private $operationsClient; + + /** + * Formats a string containing the fully-qualified path to represent + * a project resource. + */ + public static function formatProjectName($project) + { + return self::getProjectNameTemplate()->render([ + 'project' => $project, + ]); + } + + /** + * Formats a string containing the fully-qualified path to represent + * a instance_config resource. + */ + public static function formatInstanceConfigName($project, $instanceConfig) + { + return self::getInstanceConfigNameTemplate()->render([ + 'project' => $project, + 'instance_config' => $instanceConfig, + ]); + } + + /** + * Formats a string containing the fully-qualified path to represent + * a instance resource. + */ + public static function formatInstanceName($project, $instance) + { + return self::getInstanceNameTemplate()->render([ + 'project' => $project, + 'instance' => $instance, + ]); + } + + /** + * Parses the project from the given fully-qualified path which + * represents a project resource. + */ + public static function parseProjectFromProjectName($projectName) + { + return self::getProjectNameTemplate()->match($projectName)['project']; + } + + /** + * Parses the project from the given fully-qualified path which + * represents a instance_config resource. + */ + public static function parseProjectFromInstanceConfigName($instanceConfigName) + { + return self::getInstanceConfigNameTemplate()->match($instanceConfigName)['project']; + } + + /** + * Parses the instance_config from the given fully-qualified path which + * represents a instance_config resource. + */ + public static function parseInstanceConfigFromInstanceConfigName($instanceConfigName) + { + return self::getInstanceConfigNameTemplate()->match($instanceConfigName)['instance_config']; + } + + /** + * Parses the project from the given fully-qualified path which + * represents a instance resource. + */ + public static function parseProjectFromInstanceName($instanceName) + { + return self::getInstanceNameTemplate()->match($instanceName)['project']; + } + + /** + * Parses the instance from the given fully-qualified path which + * represents a instance resource. + */ + public static function parseInstanceFromInstanceName($instanceName) + { + return self::getInstanceNameTemplate()->match($instanceName)['instance']; + } + + private static function getProjectNameTemplate() + { + if (self::$projectNameTemplate == null) { + self::$projectNameTemplate = new PathTemplate('projects/{project}'); + } + + return self::$projectNameTemplate; + } + + private static function getInstanceConfigNameTemplate() + { + if (self::$instanceConfigNameTemplate == null) { + self::$instanceConfigNameTemplate = new PathTemplate('projects/{project}/instanceConfigs/{instance_config}'); + } + + return self::$instanceConfigNameTemplate; + } + + private static function getInstanceNameTemplate() + { + if (self::$instanceNameTemplate == null) { + self::$instanceNameTemplate = new PathTemplate('projects/{project}/instances/{instance}'); + } + + return self::$instanceNameTemplate; + } + + private static function getPageStreamingDescriptors() + { + $listInstanceConfigsPageStreamingDescriptor = + new PageStreamingDescriptor([ + 'requestPageTokenField' => 'page_token', + 'requestPageSizeField' => 'page_size', + 'responsePageTokenField' => 'next_page_token', + 'resourceField' => 'instance_configs', + ]); + $listInstancesPageStreamingDescriptor = + new PageStreamingDescriptor([ + 'requestPageTokenField' => 'page_token', + 'requestPageSizeField' => 'page_size', + 'responsePageTokenField' => 'next_page_token', + 'resourceField' => 'instances', + ]); + + $pageStreamingDescriptors = [ + 'listInstanceConfigs' => $listInstanceConfigsPageStreamingDescriptor, + 'listInstances' => $listInstancesPageStreamingDescriptor, + ]; + + return $pageStreamingDescriptors; + } + + private static function getLongRunningDescriptors() + { + return [ + 'createInstance' => [ + 'operationReturnType' => '\google\spanner\admin\instance\v1\Instance', + 'metadataReturnType' => '\google\spanner\admin\instance\v1\CreateInstanceMetadata', + ], + 'updateInstance' => [ + 'operationReturnType' => '\google\spanner\admin\instance\v1\Instance', + 'metadataReturnType' => '\google\spanner\admin\instance\v1\UpdateInstanceMetadata', + ], + ]; + } + + /** + * Return an OperationsClient object with the same endpoint as $this. + * + * @return \Google\GAX\LongRunning\OperationsClient + */ + public function getOperationsClient() + { + return $this->operationsClient; + } + + /** + * Resume an existing long running operation that was previously started + * by a long running API method. If $methodName is not provided, or does + * not match a long running API method, then the operation can still be + * resumed, but the OperationResponse object will not deserialize the + * final response. + * + * @param string $operationName The name of the long running operation + * @param string $methodName The name of the method used to start the operation + * + * @return \Google\GAX\OperationResponse + */ + public function resumeOperation($operationName, $methodName = null) + { + $lroDescriptors = self::getLongRunningDescriptors(); + if (!is_null($methodName) && array_key_exists($methodName, $lroDescriptors)) { + $options = $lroDescriptors[$methodName]; + } else { + $options = []; + } + $operation = new OperationResponse($operationName, $this->getOperationsClient(), $options); + $operation->reload(); + + return $operation; + } + + // TODO(garrettjones): add channel (when supported in gRPC) + /** + * Constructor. + * + * @param array $options { + * Optional. Options for configuring the service API wrapper. + * + * @type string $serviceAddress The domain name of the API remote host. + * Default 'spanner.googleapis.com'. + * @type mixed $port The port on which to connect to the remote host. Default 443. + * @type \Grpc\ChannelCredentials $sslCreds + * A `ChannelCredentials` for use with an SSL-enabled channel. + * Default: a credentials object returned from + * \Grpc\ChannelCredentials::createSsl() + * @type array $scopes A string array of scopes to use when acquiring credentials. + * Default the scopes for the Google Cloud Spanner Instance Admin API. + * @type array $retryingOverride + * An associative array of string => RetryOptions, where the keys + * are method names (e.g. 'createFoo'), that overrides default retrying + * settings. A value of null indicates that the method in question should + * not retry. + * @type int $timeoutMillis The timeout in milliseconds to use for calls + * that don't use retries. For calls that use retries, + * set the timeout in RetryOptions. + * Default: 30000 (30 seconds) + * @type string $appName The codename of the calling service. Default 'gax'. + * @type string $appVersion The version of the calling service. + * Default: the current version of GAX. + * @type \Google\Auth\CredentialsLoader $credentialsLoader + * A CredentialsLoader object created using the + * Google\Auth library. + * } + */ + public function __construct($options = []) + { + $defaultOptions = [ + 'serviceAddress' => self::SERVICE_ADDRESS, + 'port' => self::DEFAULT_SERVICE_PORT, + 'scopes' => [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/spanner.admin', + ], + 'retryingOverride' => null, + 'timeoutMillis' => self::DEFAULT_TIMEOUT_MILLIS, + 'appName' => 'gax', + 'appVersion' => AgentHeaderDescriptor::getGaxVersion(), + ]; + $options = array_merge($defaultOptions, $options); + + if (array_key_exists('operationsClient', $options)) { + $this->operationsClient = $options['operationsClient']; + } else { + $this->operationsClient = new OperationsClient([ + 'serviceAddress' => $options['serviceAddress'], + 'scopes' => $options['scopes'], + ]); + } + + $headerDescriptor = new AgentHeaderDescriptor([ + 'clientName' => $options['appName'], + 'clientVersion' => $options['appVersion'], + 'codeGenName' => self::CODEGEN_NAME, + 'codeGenVersion' => self::CODEGEN_VERSION, + 'gaxVersion' => AgentHeaderDescriptor::getGaxVersion(), + 'phpVersion' => phpversion(), + ]); + + $defaultDescriptors = ['headerDescriptor' => $headerDescriptor]; + $this->descriptors = [ + 'listInstanceConfigs' => $defaultDescriptors, + 'getInstanceConfig' => $defaultDescriptors, + 'listInstances' => $defaultDescriptors, + 'getInstance' => $defaultDescriptors, + 'createInstance' => $defaultDescriptors, + 'updateInstance' => $defaultDescriptors, + 'deleteInstance' => $defaultDescriptors, + 'setIamPolicy' => $defaultDescriptors, + 'getIamPolicy' => $defaultDescriptors, + 'testIamPermissions' => $defaultDescriptors, + ]; + $pageStreamingDescriptors = self::getPageStreamingDescriptors(); + foreach ($pageStreamingDescriptors as $method => $pageStreamingDescriptor) { + $this->descriptors[$method]['pageStreamingDescriptor'] = $pageStreamingDescriptor; + } + $longRunningDescriptors = self::getLongRunningDescriptors(); + foreach ($longRunningDescriptors as $method => $longRunningDescriptor) { + $this->descriptors[$method]['longRunningDescriptor'] = $longRunningDescriptor + ['operationsClient' => $this->operationsClient]; + } + + $clientConfigJsonString = file_get_contents(__DIR__.'/resources/instance_admin_client_config.json'); + $clientConfig = json_decode($clientConfigJsonString, true); + $this->defaultCallSettings = + CallSettings::load( + 'google.spanner.admin.instance.v1.InstanceAdmin', + $clientConfig, + $options['retryingOverride'], + GrpcConstants::getStatusCodeNames(), + $options['timeoutMillis'] + ); + + $this->scopes = $options['scopes']; + + $createStubOptions = []; + if (array_key_exists('sslCreds', $options)) { + $createStubOptions['sslCreds'] = $options['sslCreds']; + } + $grpcCredentialsHelperOptions = array_diff_key($options, $defaultOptions); + $this->grpcCredentialsHelper = new GrpcCredentialsHelper($this->scopes, $grpcCredentialsHelperOptions); + + $createInstanceAdminStubFunction = function ($hostname, $opts) { + return new InstanceAdminGrpcClient($hostname, $opts); + }; + if (array_key_exists('createInstanceAdminStubFunction', $options)) { + $createInstanceAdminStubFunction = $options['createInstanceAdminStubFunction']; + } + $this->instanceAdminStub = $this->grpcCredentialsHelper->createStub( + $createInstanceAdminStubFunction, + $options['serviceAddress'], + $options['port'], + $createStubOptions + ); + } + + /** + * Lists the supported instance configurations for a given project. + * + * Sample code: + * ``` + * try { + * $instanceAdminClient = new InstanceAdminClient(); + * $formattedParent = InstanceAdminClient::formatProjectName("[PROJECT]"); + * // Iterate through all elements + * $pagedResponse = $instanceAdminClient->listInstanceConfigs($formattedParent); + * foreach ($pagedResponse->iterateAllElements() as $element) { + * // doSomethingWith($element); + * } + * + * // OR iterate over pages of elements, with the maximum page size set to 5 + * $pagedResponse = $instanceAdminClient->listInstanceConfigs($formattedParent, ['pageSize' => 5]); + * foreach ($pagedResponse->iteratePages() as $page) { + * foreach ($page as $element) { + * // doSomethingWith($element); + * } + * } + * } finally { + * $instanceAdminClient->close(); + * } + * ``` + * + * @param string $parent Required. The name of the project for which a list of supported instance + * configurations is requested. Values are of the form + * `projects/`. + * @param array $optionalArgs { + * Optional. + * + * @type int $pageSize + * The maximum number of resources contained in the underlying API + * response. The API may return fewer values in a page, even if + * there are additional values to be retrieved. + * @type string $pageToken + * A page token is used to specify a page of values to be returned. + * If no page token is specified (the default), the first page + * of values will be returned. Any page token used here must have + * been generated by a previous call to the API. + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \Google\GAX\PagedListResponse + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function listInstanceConfigs($parent, $optionalArgs = []) + { + $request = new ListInstanceConfigsRequest(); + $request->setParent($parent); + if (isset($optionalArgs['pageSize'])) { + $request->setPageSize($optionalArgs['pageSize']); + } + if (isset($optionalArgs['pageToken'])) { + $request->setPageToken($optionalArgs['pageToken']); + } + + $mergedSettings = $this->defaultCallSettings['listInstanceConfigs']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->instanceAdminStub, + 'ListInstanceConfigs', + $mergedSettings, + $this->descriptors['listInstanceConfigs'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Gets information about a particular instance configuration. + * + * Sample code: + * ``` + * try { + * $instanceAdminClient = new InstanceAdminClient(); + * $formattedName = InstanceAdminClient::formatInstanceConfigName("[PROJECT]", "[INSTANCE_CONFIG]"); + * $response = $instanceAdminClient->getInstanceConfig($formattedName); + * } finally { + * $instanceAdminClient->close(); + * } + * ``` + * + * @param string $name Required. The name of the requested instance configuration. Values are of + * the form `projects//instanceConfigs/`. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\spanner\admin\instance\v1\InstanceConfig + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function getInstanceConfig($name, $optionalArgs = []) + { + $request = new GetInstanceConfigRequest(); + $request->setName($name); + + $mergedSettings = $this->defaultCallSettings['getInstanceConfig']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->instanceAdminStub, + 'GetInstanceConfig', + $mergedSettings, + $this->descriptors['getInstanceConfig'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Lists all instances in the given project. + * + * Sample code: + * ``` + * try { + * $instanceAdminClient = new InstanceAdminClient(); + * $formattedParent = InstanceAdminClient::formatProjectName("[PROJECT]"); + * // Iterate through all elements + * $pagedResponse = $instanceAdminClient->listInstances($formattedParent); + * foreach ($pagedResponse->iterateAllElements() as $element) { + * // doSomethingWith($element); + * } + * + * // OR iterate over pages of elements, with the maximum page size set to 5 + * $pagedResponse = $instanceAdminClient->listInstances($formattedParent, ['pageSize' => 5]); + * foreach ($pagedResponse->iteratePages() as $page) { + * foreach ($page as $element) { + * // doSomethingWith($element); + * } + * } + * } finally { + * $instanceAdminClient->close(); + * } + * ``` + * + * @param string $parent Required. The name of the project for which a list of instances is + * requested. Values are of the form `projects/`. + * @param array $optionalArgs { + * Optional. + * + * @type int $pageSize + * The maximum number of resources contained in the underlying API + * response. The API may return fewer values in a page, even if + * there are additional values to be retrieved. + * @type string $pageToken + * A page token is used to specify a page of values to be returned. + * If no page token is specified (the default), the first page + * of values will be returned. Any page token used here must have + * been generated by a previous call to the API. + * @type string $filter + * An expression for filtering the results of the request. Filter rules are + * case insensitive. The fields eligible for filtering are: + * + * * name + * * display_name + * * labels.key where key is the name of a label + * + * Some examples of using filters are: + * + * * name:* --> The instance has a name. + * * name:Howl --> The instance's name contains the string "howl". + * * name:HOWL --> Equivalent to above. + * * NAME:howl --> Equivalent to above. + * * labels.env:* --> The instance has the label "env". + * * labels.env:dev --> The instance has the label "env" and the value of + * the label contains the string "dev". + * * name:howl labels.env:dev --> The instance's name contains "howl" and + * it has the label "env" with its value + * containing "dev". + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \Google\GAX\PagedListResponse + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function listInstances($parent, $optionalArgs = []) + { + $request = new ListInstancesRequest(); + $request->setParent($parent); + if (isset($optionalArgs['pageSize'])) { + $request->setPageSize($optionalArgs['pageSize']); + } + if (isset($optionalArgs['pageToken'])) { + $request->setPageToken($optionalArgs['pageToken']); + } + if (isset($optionalArgs['filter'])) { + $request->setFilter($optionalArgs['filter']); + } + + $mergedSettings = $this->defaultCallSettings['listInstances']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->instanceAdminStub, + 'ListInstances', + $mergedSettings, + $this->descriptors['listInstances'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Gets information about a particular instance. + * + * Sample code: + * ``` + * try { + * $instanceAdminClient = new InstanceAdminClient(); + * $formattedName = InstanceAdminClient::formatInstanceName("[PROJECT]", "[INSTANCE]"); + * $response = $instanceAdminClient->getInstance($formattedName); + * } finally { + * $instanceAdminClient->close(); + * } + * ``` + * + * @param string $name Required. The name of the requested instance. Values are of the form + * `projects//instances/`. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\spanner\admin\instance\v1\Instance + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function getInstance($name, $optionalArgs = []) + { + $request = new GetInstanceRequest(); + $request->setName($name); + + $mergedSettings = $this->defaultCallSettings['getInstance']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->instanceAdminStub, + 'GetInstance', + $mergedSettings, + $this->descriptors['getInstance'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Creates an instance and begins preparing it to begin serving. The + * returned [long-running operation][google.longrunning.Operation] + * can be used to track the progress of preparing the new + * instance. The instance name is assigned by the caller. If the + * named instance already exists, `CreateInstance` returns + * `ALREADY_EXISTS`. + * + * Immediately upon completion of this request: + * + * * The instance is readable via the API, with all requested attributes + * but no allocated resources. Its state is `CREATING`. + * + * Until completion of the returned operation: + * + * * Cancelling the operation renders the instance immediately unreadable + * via the API. + * * The instance can be deleted. + * * All other attempts to modify the instance are rejected. + * + * Upon completion of the returned operation: + * + * * Billing for all successfully-allocated resources begins (some types + * may have lower than the requested levels). + * * Databases can be created in the instance. + * * The instance's allocated resource levels are readable via the API. + * * The instance's state becomes `READY`. + * + * The returned [long-running operation][google.longrunning.Operation] will + * have a name of the format `/operations/` and + * can be used to track creation of the instance. The + * [metadata][google.longrunning.Operation.metadata] field type is + * [CreateInstanceMetadata][google.spanner.admin.instance.v1.CreateInstanceMetadata]. + * The [response][google.longrunning.Operation.response] field type is + * [Instance][google.spanner.admin.instance.v1.Instance], if successful. + * + * Sample code: + * ``` + * try { + * $instanceAdminClient = new InstanceAdminClient(); + * $formattedParent = InstanceAdminClient::formatProjectName("[PROJECT]"); + * $instanceId = ""; + * $instance = new Instance(); + * $operationResponse = $instanceAdminClient->createInstance($formattedParent, $instanceId, $instance); + * $operationResponse->pollUntilComplete(); + * if ($operationResponse->operationSucceeded()) { + * $result = $operationResponse->getResult(); + * // doSomethingWith($result) + * } else { + * $error = $operationResponse->getError(); + * // handleError($error) + * } + * + * // OR start the operation, keep the operation name, and resume later + * $operationResponse = $instanceAdminClient->createInstance($formattedParent, $instanceId, $instance); + * $operationName = $operationResponse->getName(); + * // ... do other work + * $newOperationResponse = $instanceAdminClient->resumeOperation($operationName, 'createInstance'); + * while (!$newOperationResponse->isDone()) { + * // ... do other work + * $newOperationResponse->reload(); + * } + * if ($newOperationResponse->operationSucceeded()) { + * $result = $newOperationResponse->getResult(); + * // doSomethingWith($result) + * } else { + * $error = $newOperationResponse->getError(); + * // handleError($error) + * } + * } finally { + * $instanceAdminClient->close(); + * } + * ``` + * + * @param string $parent Required. The name of the project in which to create the instance. Values + * are of the form `projects/`. + * @param string $instanceId Required. The ID of the instance to create. Valid identifiers are of the + * form `[a-z][-a-z0-9]*[a-z0-9]` and must be between 6 and 30 characters in + * length. + * @param Instance $instance Required. The instance to create. The name may be omitted, but if + * specified must be `/instances/`. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\longrunning\Operation + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function createInstance($parent, $instanceId, $instance, $optionalArgs = []) + { + $request = new CreateInstanceRequest(); + $request->setParent($parent); + $request->setInstanceId($instanceId); + $request->setInstance($instance); + + $mergedSettings = $this->defaultCallSettings['createInstance']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->instanceAdminStub, + 'CreateInstance', + $mergedSettings, + $this->descriptors['createInstance'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Updates an instance, and begins allocating or releasing resources + * as requested. The returned [long-running + * operation][google.longrunning.Operation] can be used to track the + * progress of updating the instance. If the named instance does not + * exist, returns `NOT_FOUND`. + * + * Immediately upon completion of this request: + * + * * For resource types for which a decrease in the instance's allocation + * has been requested, billing is based on the newly-requested level. + * + * Until completion of the returned operation: + * + * * Cancelling the operation sets its metadata's + * [cancel_time][google.spanner.admin.instance.v1.UpdateInstanceMetadata.cancel_time], and begins + * restoring resources to their pre-request values. The operation + * is guaranteed to succeed at undoing all resource changes, + * after which point it terminates with a `CANCELLED` status. + * * All other attempts to modify the instance are rejected. + * * Reading the instance via the API continues to give the pre-request + * resource levels. + * + * Upon completion of the returned operation: + * + * * Billing begins for all successfully-allocated resources (some types + * may have lower than the requested levels). + * * All newly-reserved resources are available for serving the instance's + * tables. + * * The instance's new resource levels are readable via the API. + * + * The returned [long-running operation][google.longrunning.Operation] will + * have a name of the format `/operations/` and + * can be used to track the instance modification. The + * [metadata][google.longrunning.Operation.metadata] field type is + * [UpdateInstanceMetadata][google.spanner.admin.instance.v1.UpdateInstanceMetadata]. + * The [response][google.longrunning.Operation.response] field type is + * [Instance][google.spanner.admin.instance.v1.Instance], if successful. + * + * Authorization requires `spanner.instances.update` permission on + * resource [name][google.spanner.admin.instance.v1.Instance.name]. + * + * Sample code: + * ``` + * try { + * $instanceAdminClient = new InstanceAdminClient(); + * $instance = new Instance(); + * $fieldMask = new FieldMask(); + * $operationResponse = $instanceAdminClient->updateInstance($instance, $fieldMask); + * $operationResponse->pollUntilComplete(); + * if ($operationResponse->operationSucceeded()) { + * $result = $operationResponse->getResult(); + * // doSomethingWith($result) + * } else { + * $error = $operationResponse->getError(); + * // handleError($error) + * } + * + * // OR start the operation, keep the operation name, and resume later + * $operationResponse = $instanceAdminClient->updateInstance($instance, $fieldMask); + * $operationName = $operationResponse->getName(); + * // ... do other work + * $newOperationResponse = $instanceAdminClient->resumeOperation($operationName, 'updateInstance'); + * while (!$newOperationResponse->isDone()) { + * // ... do other work + * $newOperationResponse->reload(); + * } + * if ($newOperationResponse->operationSucceeded()) { + * $result = $newOperationResponse->getResult(); + * // doSomethingWith($result) + * } else { + * $error = $newOperationResponse->getError(); + * // handleError($error) + * } + * } finally { + * $instanceAdminClient->close(); + * } + * ``` + * + * @param Instance $instance Required. The instance to update, which must always include the instance + * name. Otherwise, only fields mentioned in [][google.spanner.admin.instance.v1.UpdateInstanceRequest.field_mask] need be included. + * @param FieldMask $fieldMask Required. A mask specifying which fields in [][google.spanner.admin.instance.v1.UpdateInstanceRequest.instance] should be updated. + * The field mask must always be specified; this prevents any future fields in + * [][google.spanner.admin.instance.v1.Instance] from being erased accidentally by clients that do not know + * about them. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\longrunning\Operation + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function updateInstance($instance, $fieldMask, $optionalArgs = []) + { + $request = new UpdateInstanceRequest(); + $request->setInstance($instance); + $request->setFieldMask($fieldMask); + + $mergedSettings = $this->defaultCallSettings['updateInstance']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->instanceAdminStub, + 'UpdateInstance', + $mergedSettings, + $this->descriptors['updateInstance'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Deletes an instance. + * + * Immediately upon completion of the request: + * + * * Billing ceases for all of the instance's reserved resources. + * + * Soon afterward: + * + * * The instance and *all of its databases* immediately and + * irrevocably disappear from the API. All data in the databases + * is permanently deleted. + * + * Sample code: + * ``` + * try { + * $instanceAdminClient = new InstanceAdminClient(); + * $formattedName = InstanceAdminClient::formatInstanceName("[PROJECT]", "[INSTANCE]"); + * $instanceAdminClient->deleteInstance($formattedName); + * } finally { + * $instanceAdminClient->close(); + * } + * ``` + * + * @param string $name Required. The name of the instance to be deleted. Values are of the form + * `projects//instances/` + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function deleteInstance($name, $optionalArgs = []) + { + $request = new DeleteInstanceRequest(); + $request->setName($name); + + $mergedSettings = $this->defaultCallSettings['deleteInstance']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->instanceAdminStub, + 'DeleteInstance', + $mergedSettings, + $this->descriptors['deleteInstance'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Sets the access control policy on an instance resource. Replaces any + * existing policy. + * + * Authorization requires `spanner.instances.setIamPolicy` on + * [resource][google.iam.v1.SetIamPolicyRequest.resource]. + * + * Sample code: + * ``` + * try { + * $instanceAdminClient = new InstanceAdminClient(); + * $formattedResource = InstanceAdminClient::formatInstanceName("[PROJECT]", "[INSTANCE]"); + * $policy = new Policy(); + * $response = $instanceAdminClient->setIamPolicy($formattedResource, $policy); + * } finally { + * $instanceAdminClient->close(); + * } + * ``` + * + * @param string $resource REQUIRED: The resource for which the policy is being specified. + * `resource` is usually specified as a path. For example, a Project + * resource is specified as `projects/{project}`. + * @param Policy $policy REQUIRED: The complete policy to be applied to the `resource`. The size of + * the policy is limited to a few 10s of KB. An empty policy is a + * valid policy but certain Cloud Platform services (such as Projects) + * might reject them. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\iam\v1\Policy + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function setIamPolicy($resource, $policy, $optionalArgs = []) + { + $request = new SetIamPolicyRequest(); + $request->setResource($resource); + $request->setPolicy($policy); + + $mergedSettings = $this->defaultCallSettings['setIamPolicy']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->instanceAdminStub, + 'SetIamPolicy', + $mergedSettings, + $this->descriptors['setIamPolicy'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Gets the access control policy for an instance resource. Returns an empty + * policy if an instance exists but does not have a policy set. + * + * Authorization requires `spanner.instances.getIamPolicy` on + * [resource][google.iam.v1.GetIamPolicyRequest.resource]. + * + * Sample code: + * ``` + * try { + * $instanceAdminClient = new InstanceAdminClient(); + * $formattedResource = InstanceAdminClient::formatInstanceName("[PROJECT]", "[INSTANCE]"); + * $response = $instanceAdminClient->getIamPolicy($formattedResource); + * } finally { + * $instanceAdminClient->close(); + * } + * ``` + * + * @param string $resource REQUIRED: The resource for which the policy is being requested. + * `resource` is usually specified as a path. For example, a Project + * resource is specified as `projects/{project}`. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\iam\v1\Policy + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function getIamPolicy($resource, $optionalArgs = []) + { + $request = new GetIamPolicyRequest(); + $request->setResource($resource); + + $mergedSettings = $this->defaultCallSettings['getIamPolicy']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->instanceAdminStub, + 'GetIamPolicy', + $mergedSettings, + $this->descriptors['getIamPolicy'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Returns permissions that the caller has on the specified instance resource. + * + * Attempting this RPC on a non-existent Cloud Spanner instance resource will + * result in a NOT_FOUND error if the user has `spanner.instances.list` + * permission on the containing Google Cloud Project. Otherwise returns an + * empty set of permissions. + * + * Sample code: + * ``` + * try { + * $instanceAdminClient = new InstanceAdminClient(); + * $formattedResource = InstanceAdminClient::formatInstanceName("[PROJECT]", "[INSTANCE]"); + * $permissions = []; + * $response = $instanceAdminClient->testIamPermissions($formattedResource, $permissions); + * } finally { + * $instanceAdminClient->close(); + * } + * ``` + * + * @param string $resource REQUIRED: The resource for which the policy detail is being requested. + * `resource` is usually specified as a path. For example, a Project + * resource is specified as `projects/{project}`. + * @param string[] $permissions The set of permissions to check for the `resource`. Permissions with + * wildcards (such as '*' or 'storage.*') are not allowed. For more + * information see + * [IAM Overview](https://cloud.google.com/iam/docs/overview#permissions). + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\iam\v1\TestIamPermissionsResponse + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function testIamPermissions($resource, $permissions, $optionalArgs = []) + { + $request = new TestIamPermissionsRequest(); + $request->setResource($resource); + foreach ($permissions as $elem) { + $request->addPermissions($elem); + } + + $mergedSettings = $this->defaultCallSettings['testIamPermissions']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->instanceAdminStub, + 'TestIamPermissions', + $mergedSettings, + $this->descriptors['testIamPermissions'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Initiates an orderly shutdown in which preexisting calls continue but new + * calls are immediately cancelled. + */ + public function close() + { + $this->instanceAdminStub->close(); + } + + private function createCredentialsCallback() + { + return $this->grpcCredentialsHelper->createCallCredentialsCallback(); + } +} diff --git a/src/Spanner/Admin/Instance/V1/resources/instance_admin_client_config.json b/src/Spanner/Admin/Instance/V1/resources/instance_admin_client_config.json new file mode 100644 index 000000000000..23dbca4fe655 --- /dev/null +++ b/src/Spanner/Admin/Instance/V1/resources/instance_admin_client_config.json @@ -0,0 +1,78 @@ +{ + "interfaces": { + "google.spanner.admin.instance.v1.InstanceAdmin": { + "retry_codes": { + "retry_codes_def": { + "idempotent": [ + "DEADLINE_EXCEEDED", + "UNAVAILABLE" + ], + "non_idempotent": [] + } + }, + "retry_params": { + "default": { + "initial_retry_delay_millis": 1000, + "retry_delay_multiplier": 1.3, + "max_retry_delay_millis": 32000, + "initial_rpc_timeout_millis": 60000, + "rpc_timeout_multiplier": 1.0, + "max_rpc_timeout_millis": 60000, + "total_timeout_millis": 600000 + } + }, + "methods": { + "ListInstanceConfigs": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "GetInstanceConfig": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "ListInstances": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "GetInstance": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "CreateInstance": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + }, + "UpdateInstance": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + }, + "DeleteInstance": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "SetIamPolicy": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + }, + "GetIamPolicy": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "TestIamPermissions": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + } + } + } + } +} diff --git a/src/Spanner/Bytes.php b/src/Spanner/Bytes.php new file mode 100644 index 000000000000..d3a17aa42094 --- /dev/null +++ b/src/Spanner/Bytes.php @@ -0,0 +1,111 @@ +bytes('hello world'); + * ``` + * + * ``` + * // Byte instances can be cast to base64-encoded strings. + * echo (string) $bytes; + * ``` + */ +class Bytes implements ValueInterface +{ + /** + * @var string|resource|StreamInterface + */ + private $value; + + /** + * @param string|resource|StreamInterface $value The bytes value. + */ + public function __construct($value) + { + $this->value = Psr7\stream_for($value); + } + + /** + * Get the bytes as a stream. + * + * Example: + * ``` + * $stream = $bytes->get(); + * ``` + * + * @return StreamInterface + */ + public function get() + { + return $this->value; + } + + /** + * Get the type. + * + * Example: + * ``` + * echo $bytes->type(); + * ``` + * + * @return string + */ + public function type() + { + return ValueMapper::TYPE_BYTES; + } + + /** + * Format the value as a string. + * + * Example: + * ``` + * echo $bytes->formatAsString(); + * ``` + * + * @return string + */ + public function formatAsString() + { + return base64_encode((string) $this->value); + } + + /** + * Format the value as a string. + * + * @return string + * @access private + */ + public function __toString() + { + return $this->formatAsString(); + } +} diff --git a/src/Spanner/Configuration.php b/src/Spanner/Configuration.php new file mode 100644 index 000000000000..d8eea3e9aead --- /dev/null +++ b/src/Spanner/Configuration.php @@ -0,0 +1,194 @@ +spanner(); + * + * $configuration = $spanner->configuration('regional-europe-west'); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.instance.v1#instanceconfig InstanceConfig + * @codingStandardsIgnoreEnd + */ +class Configuration +{ + /** + * @var ConnectionInterface + */ + private $connection; + + /** + * @var string + */ + private $projectId; + + /** + * @var string + */ + private $name; + + /** + * @var array + */ + private $info; + + /** + * Create a configuration instance. + * + * @param ConnectionInterface $connection A service connection for the + * Spanner API. + * @param string $projectId The current project ID. + * @param string $name The simple configuration name. + * @param array $info [optional] A service representation of the + * configuration. + */ + public function __construct( + ConnectionInterface $connection, + $projectId, + $name, + array $info = [] + ) { + $this->connection = $connection; + $this->projectId = $projectId; + $this->name = $name; + $this->info = $info; + } + + /** + * Return the configuration name. + * + * Example: + * ``` + * $name = $configuration->name(); + * ``` + * + * @return string + */ + public function name() + { + return $this->name; + } + + /** + * Return the service representation of the configuration. + * + * This method may require a service call. + * + * **NOTE**: Requires `https://www.googleapis.com/auth/spanner.admin` scope. + * + * Example: + * ``` + * $info = $configuration->info(); + * ``` + * + * @codingStandardsIgnoreStart + * @param array $options [optional] Configuration options. + * @return array [InstanceConfig](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.instance.v1#instanceconfig) + * @codingStandardsIgnoreEnd + */ + public function info(array $options = []) + { + if (!$this->info) { + $this->reload($options); + } + + return $this->info; + } + + /** + * Check if the configuration exists. + * + * This method requires a service call. + * + * **NOTE**: Requires `https://www.googleapis.com/auth/spanner.admin` scope. + * + * Example: + * ``` + * if ($configuration->exists()) { + * echo 'Configuration exists!'; + * } + * ``` + * + * @param array $options [optional] Configuration options. + * @return bool + */ + public function exists(array $options = []) + { + try { + $this->reload($options = []); + } catch (NotFoundException $e) { + return false; + } + + return true; + } + + /** + * Fetch a fresh representation of the configuration from the service. + * + * **NOTE**: Requires `https://www.googleapis.com/auth/spanner.admin` scope. + * + * Example: + * ``` + * $info = $configuration->reload(); + * ``` + * + * @codingStandardsIgnoreStart + * @param array $options [optional] Configuration options. + * @return array [InstanceConfig](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.instance.v1#instanceconfig) + * @codingStandardsIgnoreEnd + */ + public function reload(array $options = []) + { + $this->info = $this->connection->getConfig($options + [ + 'name' => InstanceAdminClient::formatInstanceConfigName($this->projectId, $this->name), + 'projectId' => $this->projectId + ]); + + return $this->info; + } + + /** + * A more readable representation of the object. + * + * @codeCoverageIgnore + * @access private + */ + public function __debugInfo() + { + return [ + 'connection' => get_class($this->connection), + 'projectId' => $this->projectId, + 'name' => $this->name, + 'info' => $this->info, + ]; + } +} diff --git a/src/Spanner/Connection/ConnectionInterface.php b/src/Spanner/Connection/ConnectionInterface.php new file mode 100644 index 000000000000..70e28b043b5b --- /dev/null +++ b/src/Spanner/Connection/ConnectionInterface.php @@ -0,0 +1,174 @@ + 'setInsert', + 'update' => 'setUpdate', + 'insertOrUpdate' => 'setInsertOrUpdate', + 'replace' => 'setReplace', + 'delete' => 'setDelete' + ]; + + /** + * @var array + */ + private $lroResponseMappers = [ + [ + 'method' => 'updateDatabaseDdl', + 'typeUrl' => 'type.googleapis.com/google.spanner.admin.database.v1.UpdateDatabaseDdlMetadata', + 'message' => protobuf\EmptyC::class + ], [ + 'method' => 'createDatabase', + 'typeUrl' => 'type.googleapis.com/google.spanner.admin.database.v1.CreateDatabaseMetadata', + 'message' => Database::class + ], [ + 'method' => 'createInstance', + 'typeUrl' => 'type.googleapis.com/google.spanner.admin.instance.v1.CreateInstanceMetadata', + 'message' => Instance::class + ], [ + 'method' => 'updateInstance', + 'typeUrl' => 'type.googleapis.com/google.spanner.admin.instance.v1.UpdateInstanceMetadata', + 'message' => Instance::class + ] + ]; + + /** + * @var array + */ + private $longRunningGrpcClients; + + /** + * @param array $config [optional] + */ + public function __construct(array $config = []) + { + $this->codec = new PhpArray([ + 'customFilters' => [ + 'commitTimestamp' => function ($v) { + return $this->formatTimestampFromApi($v); + }, + 'readTimestamp' => function ($v) { + return $this->formatTimestampFromApi($v); + } + ] + ]); + + $config['codec'] = $this->codec; + $this->setRequestWrapper(new GrpcRequestWrapper($config)); + + $grpcConfig = $this->getGaxConfig(VeneerSpannerClient::VERSION); + $this->instanceAdminClient = new InstanceAdminClient($grpcConfig); + $this->databaseAdminClient = new DatabaseAdminClient($grpcConfig); + $this->spannerClient = new SpannerClient($grpcConfig); + $this->operationsClient = $this->instanceAdminClient->getOperationsClient(); + + $this->longRunningGrpcClients = [ + $this->instanceAdminClient, + $this->databaseAdminClient + ]; + } + + /** + * @param array $args [optional] + */ + public function listConfigs(array $args = []) + { + return $this->send([$this->instanceAdminClient, 'listInstanceConfigs'], [ + $this->pluck('projectId', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function getConfig(array $args = []) + { + return $this->send([$this->instanceAdminClient, 'getInstanceConfig'], [ + $this->pluck('name', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function listInstances(array $args = []) + { + return $this->send([$this->instanceAdminClient, 'listInstances'], [ + $this->pluck('projectId', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function getInstance(array $args = []) + { + return $this->send([$this->instanceAdminClient, 'getInstance'], [ + $this->pluck('name', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function createInstance(array $args = []) + { + $instance = $this->instanceObject($args, true); + $res = $this->send([$this->instanceAdminClient, 'createInstance'], [ + $this->pluck('projectId', $args), + $this->pluck('instanceId', $args), + $instance, + $args + ]); + + return $this->operationToArray($res, $this->codec, $this->lroResponseMappers); + } + + /** + * @param array $args [optional] + */ + public function updateInstance(array $args = []) + { + $instanceObject = $this->instanceObject($args); + + $mask = array_keys($instanceObject->serialize(new PhpArray(['useCamelCase' => false]))); + + $fieldMask = (new protobuf\FieldMask())->deserialize(['paths' => $mask], $this->codec); + + $res = $this->send([$this->instanceAdminClient, 'updateInstance'], [ + $instanceObject, + $fieldMask, + $args + ]); + + return $this->operationToArray($res, $this->codec, $this->lroResponseMappers); + } + + /** + * @param array $args [optional] + */ + public function deleteInstance(array $args = []) + { + return $this->send([$this->instanceAdminClient, 'deleteInstance'], [ + $this->pluck('name', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function getInstanceIamPolicy(array $args = []) + { + return $this->send([$this->instanceAdminClient, 'getIamPolicy'], [ + $this->pluck('resource', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function setInstanceIamPolicy(array $args = []) + { + return $this->send([$this->instanceAdminClient, 'setIamPolicy'], [ + $this->pluck('resource', $args), + $this->pluck('policy', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function testInstanceIamPermissions(array $args = []) + { + return $this->send([$this->instanceAdminClient, 'testIamPermissions'], [ + $this->pluck('resource', $args), + $this->pluck('permissions', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function listDatabases(array $args = []) + { + return $this->send([$this->databaseAdminClient, 'listDatabases'], [ + $this->pluck('instance', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function createDatabase(array $args = []) + { + $res = $this->send([$this->databaseAdminClient, 'createDatabase'], [ + $this->pluck('instance', $args), + $this->pluck('createStatement', $args), + $this->pluck('extraStatements', $args), + $args + ]); + + return $this->operationToArray($res, $this->codec, $this->lroResponseMappers); + } + + /** + * @param array $args [optional] + */ + public function updateDatabase(array $args = []) + { + $res = $this->send([$this->databaseAdminClient, 'updateDatabaseDdl'], [ + $this->pluck('name', $args), + $this->pluck('statements', $args), + $args + ]); + + return $this->operationToArray($res, $this->codec, $this->lroResponseMappers); + } + + /** + * @param array $args [optional] + */ + public function dropDatabase(array $args = []) + { + return $this->send([$this->databaseAdminClient, 'dropDatabase'], [ + $this->pluck('name', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function getDatabaseDDL(array $args = []) + { + return $this->send([$this->databaseAdminClient, 'getDatabaseDDL'], [ + $this->pluck('name', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function getDatabaseIamPolicy(array $args = []) + { + return $this->send([$this->databaseAdminClient, 'getIamPolicy'], [ + $this->pluck('resource', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function setDatabaseIamPolicy(array $args = []) + { + return $this->send([$this->databaseAdminClient, 'setIamPolicy'], [ + $this->pluck('resource', $args), + $this->pluck('policy', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function testDatabaseIamPermissions(array $args = []) + { + return $this->send([$this->databaseAdminClient, 'testIamPermissions'], [ + $this->pluck('resource', $args), + $this->pluck('permissions', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function createSession(array $args = []) + { + return $this->send([$this->spannerClient, 'createSession'], [ + $this->pluck('database', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function getSession(array $args = []) + { + return $this->send([$this->spannerClient, 'getSession'], [ + $this->pluck('name', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function deleteSession(array $args = []) + { + return $this->send([$this->spannerClient, 'deleteSession'], [ + $this->pluck('name', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function executeSql(array $args = []) + { + $params = $this->pluck('params', $args); + if ($params) { + $args['params'] = (new protobuf\Struct) + ->deserialize($this->formatStructForApi($params), $this->codec); + } + + foreach ($args['paramTypes'] as $key => $param) { + $args['paramTypes'][$key] = (new Type) + ->deserialize($param, $this->codec); + } + + $args['transaction'] = $this->createTransactionSelector($args); + + return $this->send([$this->spannerClient, 'executeSql'], [ + $this->pluck('session', $args), + $this->pluck('sql', $args), + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function read(array $args = []) + { + $keySet = $this->pluck('keySet', $args); + $keySet = (new KeySet) + ->deserialize($this->formatKeySet($keySet), $this->codec); + + $args['transaction'] = $this->createTransactionSelector($args); + + return $this->send([$this->spannerClient, 'read'], [ + $this->pluck('session', $args), + $this->pluck('table', $args), + $this->pluck('columns', $args), + $keySet, + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function beginTransaction(array $args = []) + { + $options = new TransactionOptions; + + if (isset($args['transactionOptions']['readOnly'])) { + $ro = $args['transactionOptions']['readOnly']; + + if (isset($ro['minReadTimestamp'])) { + $ro['minReadTimestamp'] = $this->formatTimestampForApi($ro['minReadTimestamp']); + } + + if (isset($ro['readTimestamp'])) { + $ro['readTimestamp'] = $this->formatTimestampForApi($ro['readTimestamp']); + } + + $readOnly = (new TransactionOptions\ReadOnly) + ->deserialize($ro, $this->codec); + + $options->setReadOnly($readOnly); + } else { + $readWrite = new TransactionOptions\ReadWrite(); + $options->setReadWrite($readWrite); + } + + return $this->send([$this->spannerClient, 'beginTransaction'], [ + $this->pluck('session', $args), + $options, + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function commit(array $args = []) + { + $inputMutations = $this->pluck('mutations', $args); + + $mutations = []; + if (is_array($inputMutations)) { + foreach ($inputMutations as $mutation) { + $type = array_keys($mutation)[0]; + $data = $mutation[$type]; + + switch ($type) { + case 'delete': + if (isset($data['keySet'])) { + $data['keySet'] = $this->formatKeySet($data['keySet']); + } + + $operation = (new Mutation\Delete) + ->deserialize($data, $this->codec); + + break; + default: + $data['values'] = $this->formatListForApi($data['values']); + + $operation = (new Mutation\Write) + ->deserialize($data, $this->codec); + + break; + } + + $setterName = $this->mutationSetters[$type]; + $mutation = new Mutation; + $mutation->$setterName($operation); + $mutations[] = $mutation; + } + } + + if (isset($args['singleUseTransaction'])) { + $readWrite = (new TransactionOptions\ReadWrite) + ->deserialize([], $this->codec); + + $options = new TransactionOptions; + $options->setReadWrite($readWrite); + $args['singleUseTransaction'] = $options; + } + + return $this->send([$this->spannerClient, 'commit'], [ + $this->pluck('session', $args), + $mutations, + $args + ]); + } + + /** + * @param array $args [optional] + */ + public function rollback(array $args = []) + { + return $this->send([$this->spannerClient, 'rollback'], [ + $this->pluck('session', $args), + $this->pluck('transactionId', $args), + $args + ]); + } + + /** + * @param array $args + */ + public function getOperation(array $args) + { + $name = $this->pluck('name', $args); + + $operation = $this->getOperationByName($this->databaseAdminClient, $name); + + return $this->operationToArray($operation, $this->codec, $this->lroResponseMappers); + } + + /** + * @param array $args + */ + public function cancelOperation(array $args) + { + $name = $this->pluck('name', $args); + $method = $this->pluck('method', $args); + + $operation = $this->getOperationByNameAndMethod($name, $method); + } + + /** + * @param array $args + */ + public function deleteOperation(array $args) + { + $name = $this->pluck('name', $args); + $method = $this->pluck('method', $args); + + $operation = $this->getOperationByNameAndMethod($name, $method); + } + + /** + * @param array $args + */ + public function listOperations(array $args) + { + $name = $this->pluck('name', $args); + $method = $this->pluck('method', $args); + } + + /** + * @param array $keySet + * @return array Formatted keyset + */ + private function formatKeySet(array $keySet) + { + if (isset($keySet['keys'])) { + $keySet['keys'] = $this->formatListForApi($keySet['keys']); + } + + if (isset($keySet['ranges'])) { + foreach ($keySet['ranges'] as $index => $rangeItem) { + foreach ($rangeItem as $key => $val) { + $rangeItem[$key] = $this->formatListForApi($val); + } + + $keySet['ranges'][$index] = $rangeItem; + } + + if (empty($keySet['ranges'])) { + unset($keySet['ranges']); + } + } + + return $keySet; + } + + /** + * @param array $args + * @return array + */ + private function createTransactionSelector(array &$args) + { + $selector = new TransactionSelector; + if (isset($args['transaction'])) { + $selector = $selector->deserialize($this->pluck('transaction', $args), $this->codec); + } elseif (isset($args['transactionId'])) { + $selector = $selector->deserialize(['id' => $this->pluck('transactionId', $args)], $this->codec); + } + + return $selector; + } + + /** + * @param array $args + * @param bool $isRequired + */ + private function instanceObject(array &$args, $required = false) + { + return (new Instance())->deserialize(array_filter([ + 'name' => $this->pluck('name', $args, $required), + 'config' => $this->pluck('config', $args, $required), + 'displayName' => $this->pluck('displayName', $args, $required), + 'nodeCount' => $this->pluck('nodeCount', $args, $required), + 'state' => $this->pluck('state', $args, $required), + 'labels' => $this->formatLabelsForApi($this->pluck('labels', $args, $required)) + ]), $this->codec); + } +} diff --git a/src/Spanner/Connection/IamDatabase.php b/src/Spanner/Connection/IamDatabase.php new file mode 100644 index 000000000000..f76eb92b11a6 --- /dev/null +++ b/src/Spanner/Connection/IamDatabase.php @@ -0,0 +1,63 @@ +connection = $connection; + } + + /** + * @param array $args + */ + public function getPolicy(array $args) + { + return $this->connection->getDatabaseIamPolicy($args); + } + + /** + * @param array $args + */ + public function setPolicy(array $args) + { + return $this->connection->setDatabaseIamPolicy($args); + } + + /** + * @param array $args + */ + public function testPermissions(array $args) + { + return $this->connection->testDatabaseIamPermissions($args); + } +} diff --git a/src/Spanner/Connection/IamInstance.php b/src/Spanner/Connection/IamInstance.php new file mode 100644 index 000000000000..5dac2b7a7c66 --- /dev/null +++ b/src/Spanner/Connection/IamInstance.php @@ -0,0 +1,63 @@ +connection = $connection; + } + + /** + * @param array $args + */ + public function getPolicy(array $args) + { + return $this->connection->getInstanceIamPolicy($args); + } + + /** + * @param array $args + */ + public function setPolicy(array $args) + { + return $this->connection->setInstanceIamPolicy($args); + } + + /** + * @param array $args + */ + public function testPermissions(array $args) + { + return $this->connection->testInstanceIamPermissions($args); + } +} diff --git a/src/Spanner/Connection/LongRunningConnection.php b/src/Spanner/Connection/LongRunningConnection.php new file mode 100644 index 000000000000..5b75467f25df --- /dev/null +++ b/src/Spanner/Connection/LongRunningConnection.php @@ -0,0 +1,72 @@ +connection = $connection; + } + + /** + * @param array $args + */ + public function get(array $args) + { + return $this->connection->getOperation($args); + } + + /** + * @param array $args + */ + public function cancel(array $args) + { + return $this->connection->cancelOperation($args); + } + + /** + * @param array $args + */ + public function delete(array $args) + { + return $this->connection->deleteOperation($args); + } + + /** + * @param array $args + */ + public function operations(array $args) + { + return $this->connection->listOperations($args); + } +} diff --git a/src/Spanner/Database.php b/src/Spanner/Database.php new file mode 100644 index 000000000000..32ff682b2638 --- /dev/null +++ b/src/Spanner/Database.php @@ -0,0 +1,1122 @@ +spanner(); + * + * $database = $spanner->connect('my-instance', 'my-database'); + * ``` + * + * ``` + * // Databases can also be connected to via an Instance. + * use Google\Cloud\ServiceBuilder; + * + * $cloud = new ServiceBuilder(); + * $spanner = $cloud->spanner(); + * + * $instance = $spanner->instance('my-instance'); + * $database = $instance->database('my-database'); + * ``` + */ +class Database +{ + use LROTrait; + use TransactionConfigurationTrait; + + const MAX_RETRIES = 3; + + /** + * @var ConnectionInterface + */ + private $connection; + + /** + * @var Instance + */ + private $instance; + + /** + * @var SessionPoolInterface + */ + private $sessionPool; + + /** + * @var LongRunningConnectionInterface + */ + private $lroConnection; + + /** + * @var Operation + */ + private $operation; + + /** + * @var string + */ + private $projectId; + + /** + * @var string + */ + private $name; + + /** + * @var Iam + */ + private $iam; + + /** + * Create an object representing a Database. + * + * @param ConnectionInterface $connection The connection to the + * Google Cloud Spanner Admin API. + * @param Instance $instance The instance in which the database exists. + * @param SessionPoolInterface $sessionPool The session pool implementation. + * @param LongRunningConnectionInterface $lroConnection An implementation + * mapping to methods which handle LRO resolution in the service. + * @param string $projectId The project ID. + * @param string $name The database name. + * @param bool $returnInt64AsObject If true, 64 bit integers will be + * returned as a {@see Google\Cloud\Core\Int64} object for 32 bit + * platform compatibility. **Defaults to** false. + */ + public function __construct( + ConnectionInterface $connection, + Instance $instance, + SessionPoolInterface $sessionPool, + LongRunningConnectionInterface $lroConnection, + array $lroCallables, + $projectId, + $name, + $returnInt64AsObject = false + ) { + $this->connection = $connection; + $this->instance = $instance; + $this->sessionPool = $sessionPool; + $this->lroConnection = $lroConnection; + $this->lroCallables = $lroCallables; + $this->projectId = $projectId; + $this->name = $name; + + $this->operation = new Operation($connection, $returnInt64AsObject); + } + + /** + * Return the simple database name. + * + * Example: + * ``` + * $name = $database->name(); + * ``` + * + * @return string + */ + public function name() + { + return $this->name; + } + + /** + * Check if the database exists. + * + * This method sends a service request. + * + * **NOTE**: Requires `https://www.googleapis.com/auth/spanner.admin` scope. + * + * Example: + * ``` + * if ($database->exists()) { + * echo 'Database exists!'; + * } + * ``` + * + * @param array $options [optional] Configuration options. + * @return bool + */ + public function exists(array $options = []) + { + try { + $this->ddl($options); + } catch (NotFoundException $e) { + return false; + } + + return true; + } + + /** + * Update the Database schema by running a SQL statement. + * + * **NOTE**: Requires `https://www.googleapis.com/auth/spanner.admin` scope. + * + * Example: + * ``` + * $database->updateDdl( + * 'CREATE TABLE Users ( + * id INT64 NOT NULL, + * name STRING(100) NOT NULL + * password STRING(100) NOT NULL + * )' + * ); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/docs/data-definition-language Data Definition Language + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.admin.database.v1#google.spanner.admin.database.v1.UpdateDatabaseDdlRequest UpdateDDLRequest + * @codingStandardsIgnoreEnd + * + * @param string $statement A DDL statements to run against a database. + * @param array $options [optional] Configuration options. + * @return LongRunningOperation + */ + public function updateDdl($statement, array $options = []) + { + return $this->updateDdlBatch([$statement], $options); + } + + /** + * Update the Database schema by running a set of SQL statements. + * + * **NOTE**: Requires `https://www.googleapis.com/auth/spanner.admin` scope. + * + * Example: + * ``` + * $database->updateDdlBatch([ + * 'CREATE TABLE Users ( + * id INT64 NOT NULL, + * name STRING(100) NOT NULL, + * password STRING(100) NOT NULL + * ) PRIMARY KEY (id)', + * 'CREATE TABLE Posts ( + * id INT64 NOT NULL, + * title STRING(100) NOT NULL, + * content STRING(MAX) NOT NULL + * ) PRIMARY KEY(id)' + * ]); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/docs/data-definition-language Data Definition Language + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.admin.database.v1#google.spanner.admin.database.v1.UpdateDatabaseDdlRequest UpdateDDLRequest + * @codingStandardsIgnoreEnd + * + * @param string[] $statements A list of DDL statements to run against a database. + * @param array $options [optional] Configuration options. + * @return LongRunningOperation + */ + public function updateDdlBatch(array $statements, array $options = []) + { + $operation = $this->connection->updateDatabase($options + [ + 'name' => $this->fullyQualifiedDatabaseName(), + 'statements' => $statements, + ]); + + return $this->lro($this->lroConnection, $operation['name'], $this->lroCallables); + } + + /** + * Drop the database. + * + * **NOTE**: Requires `https://www.googleapis.com/auth/spanner.admin` scope. + * + * Example: + * ``` + * $database->drop(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.admin.database.v1#google.spanner.admin.database.v1.DropDatabaseRequest DropDatabaseRequest + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] Configuration options. + * @return void + */ + public function drop(array $options = []) + { + $this->connection->dropDatabase($options + [ + 'name' => $this->fullyQualifiedDatabaseName() + ]); + } + + /** + * Get a list of all database DDL statements. + * + * **NOTE**: Requires `https://www.googleapis.com/auth/spanner.admin` scope. + * + * Example: + * ``` + * $statements = $database->ddl(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.admin.database.v1#getdatabaseddlrequest GetDatabaseDdlRequest + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] Configuration options. + * @return array + */ + public function ddl(array $options = []) + { + $ddl = $this->connection->getDatabaseDDL($options + [ + 'name' => $this->fullyQualifiedDatabaseName() + ]); + + if (isset($ddl['statements'])) { + return $ddl['statements']; + } + + return []; + } + + /** + * Manage the database IAM policy + * + * Example: + * ``` + * $iam = $database->iam(); + * ``` + * + * @return Iam + */ + public function iam() + { + if (!$this->iam) { + $this->iam = new Iam( + new IamDatabase($this->connection), + $this->fullyQualifiedDatabaseName() + ); + } + + return $this->iam; + } + + /** + * Create a snapshot to read from a database at a point in time. + * + * If no configuration options are provided, transaction will be opened with + * strong consistency. + * + * Snapshots are executed behind the scenes using a Read-Only Transaction. + * + * Example: + * ``` + * $snapshot = $database->snapshot(); + * ``` + * + * ``` + * // Take a shapshot with a returned timestamp. + * $snapshot = $database->snapshot([ + * 'returnReadTimestamp' => true + * ]); + * + * $timestamp = $snapshot->readTimestamp(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest + * @see https://cloud.google.com/spanner/docs/transactions Transactions + * + * @param array $options [optional] { + * Configuration Options + * + * See [ReadOnly](https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.TransactionOptions.ReadOnly) + * for detailed description of available options. + * + * Please note that only one of `$strong`, `$readTimestamp` or + * `$exactStaleness` may be set in a request. + * + * @type bool $returnReadTimestamp If true, the Cloud Spanner-selected + * read timestamp is included in the Transaction message that + * describes the transaction. + * @type bool $strong Read at a timestamp where all previously committed + * transactions are visible. + * @type Timestamp $readTimestamp Executes all reads at the given + * timestamp. + * @type Duration $exactStaleness Represents a number of seconds. Executes + * all reads at a timestamp that is $exactStaleness old. + * } + * @return Snapshot + * @codingStandardsIgnoreEnd + */ + public function snapshot(array $options = []) + { + // These are only available in single-use transactions. + if (isset($options['maxStaleness']) || isset($options['minReadTimestamp'])) { + throw new \BadMethodCallException( + 'maxStaleness and minReadTimestamp are only available in single-use transactions.' + ); + } + + $transactionOptions = $this->configureSnapshotOptions($options); + + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READ); + + return $this->operation->snapshot($session, $transactionOptions); + } + + /** + * Execute Read/Write operations inside a Transaction. + * + * Using this method and providing a callable operation provides certain + * benefits including automatic retry when a transaction fails. In case of a + * failure, all transaction operations, including reads, are re-applied in a + * new transaction. + * + * If a transaction exceeds the maximum number of retries, + * {@see Google\Cloud\Core\Exception\AbortedException} will be thrown. Any other + * exception types will immediately bubble up and will interrupt the retry + * operation. + * + * Please note that once a transaction reads data, it will lock the read + * data, preventing other users from modifying that data. For this reason, + * it is important that every transaction commits or rolls back as early as + * possible. Do not hold transactions open longer than necessary. + * + * If you have an active transaction which was obtained from elsewhere, you + * can provide it to this method and gain the benefits of managed retry by + * setting `$options.transaction` to your {@see Google\Cloud\Spanner\Transaction} + * instance. Please note that in this case, it is important that ALL reads + * and mutations MUST be performed within the runTransaction callable. + * + * Example: + * ``` + * $transaction = $database->runTransaction(function (Transaction $t) use ($username, $password) { + * $user = $t->execute('SELECT * FROM Users WHERE Name = @name and PasswordHash = @password', [ + * 'parameters' => [ + * 'name' => $username, + * 'password' => password_hash($password, PASSWORD_DEFAULT) + * ] + * ])->firstRow(); + * + * if ($user) { + * // Do something here to grant the user access. + * // Maybe set a cookie? + * + * $user['loginCount'] = $user['loginCount'] + 1; + * $t->update('Users', $user); + * + * $t->commit(); + * } else { + * $t->rollback(); + * } + * }); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest + * @see https://cloud.google.com/spanner/docs/transactions Transactions + * @codingStandardsIgnoreEnd + * + * @param callable $operation The operations to run in the transaction. + * **Signature:** `function (Transaction $transaction)`. + * @param array $options [optional] { + * Configuration Options + * + * @type int $maxRetries The number of times to attempt to apply the + * operation before failing. **Defaults to ** `3`. + * @type Transaction $transaction If provided, the transaction will be + * passed to the callable instead of attempting to begin a new + * transaction. + * } + * @return mixed The return value of `$operation`. + */ + public function runTransaction(callable $operation, array $options = []) + { + $options += [ + 'maxRetries' => self::MAX_RETRIES, + 'transaction' => null + ]; + + // There isn't anything configurable here. + $options['transactionOptions'] = $this->configureTransactionOptions(); + + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); + + $attempt = 0; + $startTransactionFn = function ($session, $options) use (&$attempt) { + if ($attempt === 0 && $options['transaction'] instanceof Transaction) { + $transaction = $options['transaction']; + } elseif ($attempt === 0 && $options['transaction']) { + throw new \InvalidArgumentException('Given transaction must be an instance of Transaction.'); + } else { + $transaction = $this->operation->transaction($session, $options); + } + + $attempt++; + return $transaction; + }; + + $delayFn = function (\Exception $e) { + if (!($e instanceof AbortedException)) { + throw $e; + } + + $delay = $e->getRetryDelay(); + time_nanosleep($delay['seconds'], $delay['nanos']); + }; + + $commitFn = function ($operation, $session, $options) use ($startTransactionFn) { + $transaction = call_user_func_array($startTransactionFn, [ + $session, + $options + ]); + + return call_user_func($operation, $transaction); + }; + + $retry = new Retry($options['maxRetries'], $delayFn); + return $retry->execute($commitFn, [$operation, $session, $options]); + } + + /** + * Create and return a new read/write Transaction. + * + * When manually using a Transaction, it is advised that retry logic be + * implemented to reapply all operations when an instance of + * {@see Google\Cloud\Core\Exception\AbortedException} is thrown. + * + * If you wish Google Cloud PHP to handle retry logic for you (recommended + * for most cases), use {@see Google\Cloud\Spanner\Database::runTransaction()}. + * + * Please note that once a transaction reads data, it will lock the read + * data, preventing other users from modifying that data. For this reason, + * it is important that every transaction commits or rolls back as early as + * possible. Do not hold transactions open longer than necessary. + * + * Example: + * ``` + * $transaction = $database->transaction(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest + * @see https://cloud.google.com/spanner/docs/transactions Transactions + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] Configuration Options. + * @return Transaction + */ + public function transaction(array $options = []) + { + // There isn't anything configurable here. + $options['transactionOptions'] = [ + 'readWrite' => [] + ]; + + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); + return $this->operation->transaction($session, $options); + } + + /** + * Insert a row. + * + * Mutations are committed in a single-use transaction. + * + * Example: + * ``` + * $database->insert('Posts', [ + * 'ID' => 1337, + * 'postTitle' => 'Hello World!', + * 'postContent' => 'Welcome to our site.' + * ]); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.CommitRequest CommitRequest + * @codingStandardsIgnoreEnd + * + * @param string $table The table to mutate. + * @param array $data The row data to insert. + * @param array $options [optional] Configuration options. + * @return Timestamp The commit Timestamp. + */ + public function insert($table, array $data, array $options = []) + { + return $this->insertBatch($table, [$data], $options); + } + + /** + * Insert multiple rows. + * + * Mutations are committed in a single-use transaction. + * + * Example: + * ``` + * $database->insertBatch('Posts', [ + * [ + * 'ID' => 1337, + * 'postTitle' => 'Hello World!', + * 'postContent' => 'Welcome to our site.' + * ], [ + * 'ID' => 1338, + * 'postTitle' => 'Our History', + * 'postContent' => 'Lots of people ask about where we got started.' + * ] + * ]); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.CommitRequest CommitRequest + * @codingStandardsIgnoreEnd + * + * @param string $table The table to mutate. + * @param array $dataSet The row data to insert. + * @param array $options [optional] Configuration options. + * @return Timestamp The commit Timestamp. + */ + public function insertBatch($table, array $dataSet, array $options = []) + { + $mutations = []; + foreach ($dataSet as $data) { + $mutations[] = $this->operation->mutation(Operation::OP_INSERT, $table, $data); + } + + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); + + $options['singleUseTransaction'] = $this->configureTransactionOptions(); + return $this->operation->commit($session, $mutations, $options); + } + + /** + * Update a row. + * + * Only data which you wish to update need be included. You must provide + * enough information for the API to determine which row should be modified. + * In most cases, this means providing values for the Primary Key fields. + * + * Mutations are committed in a single-use transaction. + * + * Example: + * ``` + * $database->update('Posts', [ + * 'ID' => 1337, + * 'postContent' => 'Thanks for visiting our site!' + * ]); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.CommitRequest CommitRequest + * @codingStandardsIgnoreEnd + * + * @param string $table The table to mutate. + * @param array $data The row data to update. + * @param array $options [optional] Configuration options. + * @return Timestamp The commit Timestamp. + */ + public function update($table, array $data, array $options = []) + { + return $this->updateBatch($table, [$data], $options); + } + + /** + * Update multiple rows. + * + * Only data which you wish to update need be included. You must provide + * enough information for the API to determine which row should be modified. + * In most cases, this means providing values for the Primary Key fields. + * + * Mutations are committed in a single-use transaction. + * + * Example: + * ``` + * $database->updateBatch('Posts', [ + * [ + * 'ID' => 1337, + * 'postContent' => 'Thanks for visiting our site!' + * ], [ + * 'ID' => 1338, + * 'postContent' => 'A little bit about us!' + * ] + * ]); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.CommitRequest CommitRequest + * @codingStandardsIgnoreEnd + * + * @param string $table The table to mutate. + * @param array $dataSet The row data to update. + * @param array $options [optional] Configuration options. + * @return Timestamp The commit Timestamp. + */ + public function updateBatch($table, array $dataSet, array $options = []) + { + $mutations = []; + foreach ($dataSet as $data) { + $mutations[] = $this->operation->mutation(Operation::OP_UPDATE, $table, $data); + } + + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); + + $options['singleUseTransaction'] = $this->configureTransactionOptions(); + return $this->operation->commit($session, $mutations, $options); + } + + /** + * Insert or update a row. + * + * If a row already exists (determined by comparing the Primary Key to + * existing table data), the row will be updated. If not, it will be + * created. + * + * Mutations are committed in a single-use transaction. + * + * Example: + * ``` + * $database->insertOrUpdate('Posts', [ + * 'ID' => 1337, + * 'postTitle' => 'Hello World!', + * 'postContent' => 'Thanks for visiting our site!' + * ]); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.CommitRequest CommitRequest + * @codingStandardsIgnoreEnd + * + * @param string $table The table to mutate. + * @param array $data The row data to insert or update. + * @param array $options [optional] Configuration options. + * @return Timestamp The commit Timestamp. + */ + public function insertOrUpdate($table, array $data, array $options = []) + { + return $this->insertOrUpdateBatch($table, [$data], $options); + } + + /** + * Insert or update multiple rows. + * + * If a row already exists (determined by comparing the Primary Key to + * existing table data), the row will be updated. If not, it will be + * created. + * + * Mutations are committed in a single-use transaction. + * + * Example: + * ``` + * $database->insertOrUpdateBatch('Posts', [ + * [ + * 'ID' => 1337, + * 'postTitle' => 'Hello World!', + * 'postContent' => 'Thanks for visiting our site!' + * ], [ + * 'ID' => 1338, + * 'postTitle' => 'Our History', + * 'postContent' => 'A little bit about us!' + * ] + * ]); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.CommitRequest CommitRequest + * @codingStandardsIgnoreEnd + * + * @param string $table The table to mutate. + * @param array $dataSet The row data to insert or update. + * @param array $options [optional] Configuration options. + * @return Timestamp The commit Timestamp. + */ + public function insertOrUpdateBatch($table, array $dataSet, array $options = []) + { + $mutations = []; + foreach ($dataSet as $data) { + $mutations[] = $this->operation->mutation(Operation::OP_INSERT_OR_UPDATE, $table, $data); + } + + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); + + $options['singleUseTransaction'] = $this->configureTransactionOptions(); + return $this->operation->commit($session, $mutations, $options); + } + + /** + * Replace a row. + * + * Provide data for the entire row. Google Cloud Spanner will attempt to + * find a record matching the Primary Key, and will replace the entire row. + * If a matching row is not found, it will be inserted. + * + * Mutations are committed in a single-use transaction. + * + * Example: + * ``` + * $database->replace('Posts', [ + * 'ID' => 1337, + * 'postTitle' => 'Hello World!', + * 'postContent' => 'Thanks for visiting our site!' + * ]); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.CommitRequest CommitRequest + * @codingStandardsIgnoreEnd + * + * @param string $table The table to mutate. + * @param array $data The row data to replace. + * @param array $options [optional] Configuration options. + * @return Timestamp The commit Timestamp. + */ + public function replace($table, array $data, array $options = []) + { + return $this->replaceBatch($table, [$data], $options); + } + + /** + * Replace multiple rows. + * + * Provide data for the entire row. Google Cloud Spanner will attempt to + * find a record matching the Primary Key, and will replace the entire row. + * If a matching row is not found, it will be inserted. + * + * Mutations are committed in a single-use transaction. + * + * Example: + * ``` + * $database->replaceBatch('Posts', [ + * [ + * 'ID' => 1337, + * 'postTitle' => 'Hello World!', + * 'postContent' => 'Thanks for visiting our site!' + * ], [ + * 'ID' => 1338, + * 'postTitle' => 'Our History', + * 'postContent' => 'A little bit about us!' + * ] + * ]); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.CommitRequest CommitRequest + * @codingStandardsIgnoreEnd + * + * @param string $table The table to mutate. + * @param array $dataSet The row data to replace. + * @param array $options [optional] Configuration options. + * @return Timestamp The commit Timestamp. + */ + public function replaceBatch($table, array $dataSet, array $options = []) + { + $mutations = []; + foreach ($dataSet as $data) { + $mutations[] = $this->operation->mutation(Operation::OP_REPLACE, $table, $data); + } + + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); + + $options['singleUseTransaction'] = $this->configureTransactionOptions(); + return $this->operation->commit($session, $mutations, $options); + } + + /** + * Delete one or more rows. + * + * Mutations are committed in a single-use transaction. + * + * Example: + * ``` + * $keySet = new KeySet([ + * 'keys' => [ + * 1337, 1338 + * ] + * ]); + * + * $database->delete('Posts', $keySet); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.CommitRequest CommitRequest + * @codingStandardsIgnoreEnd + * + * @param string $table The table to mutate. + * @param KeySet $keySet The KeySet to identify rows to delete. + * @param array $options [optional] Configuration options. + * @return Timestamp The commit Timestamp. + */ + public function delete($table, KeySet $keySet, array $options = []) + { + $mutations = [$this->operation->deleteMutation($table, $keySet)]; + + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); + + $options['singleUseTransaction'] = $this->configureTransactionOptions(); + return $this->operation->commit($session, $mutations, $options); + } + + /** + * Run a query. + * + * Example: + * ``` + * $result = $database->execute('SELECT * FROM Posts WHERE ID = @postId', [ + * 'parameters' => [ + * 'postId' => 1337 + * ] + * ]); + * ``` + * + * ``` + * // Execute a read and return a new Snapshot for further reads. + * $result = $database->execute('SELECT * FROM Posts WHERE ID = @postId', [ + * 'parameters' => [ + * 'postId' => 1337 + * ], + * 'begin' => true + * ]); + * + * $snapshot = $result->snapshot(); + * ``` + * + * ``` + * // Execute a read and return a new Transaction for further reads and writes. + * $result = $database->execute('SELECT * FROM Posts WHERE ID = @postId', [ + * 'parameters' => [ + * 'postId' => 1337 + * ], + * 'begin' => true, + * 'transactionType' => SessionPoolInterface::CONTEXT_READWRITE + * ]); + * + * $transaction = $result->transaction(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.ExecuteSqlRequest ExecuteSqlRequest + * @codingStandardsIgnoreEnd + * + * @codingStandardsIgnoreStart + * @param string $sql The query string to execute. + * @param array $options [optional] { + * Configuration Options. + * + * See [TransactionOptions](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.TransactionOptions) + * for detailed description of available transaction options. + * + * Please note that only one of `$strong`, `$minReadTimestamp`, + * `$maxStaleness`, `$readTimestamp` or `$exactStaleness` may be set in + * a request. + * + * @type array $parameters A key/value array of Query Parameters, where + * the key is represented in the query string prefixed by a `@` + * symbol. + * @type bool $returnReadTimestamp If true, the Cloud Spanner-selected + * read timestamp is included in the Transaction message that + * describes the transaction. + * @type bool $strong Read at a timestamp where all previously committed + * transactions are visible. + * @type Timestamp $minReadTimestamp Execute reads at a timestamp >= the + * given timestamp. Only available in single-use transactions. + * @type Duration $maxStaleness Read data at a timestamp >= NOW - the + * given timestamp. Only available in single-use transactions. + * @type Timestamp $readTimestamp Executes all reads at the given + * timestamp. + * @type Duration $exactStaleness Represents a number of seconds. Executes + * all reads at a timestamp that is $exactStaleness old. + * @type bool $begin If true, will begin a new transaction. If a + * read/write transaction is desired, set the value of + * $transactionType. If a transaction or snapshot is created, it + * will be returned as `$result->transaction()` or + * `$result->snapshot()`. **Defaults to** `false`. + * @type string $transactionType One of `SessionPoolInterface::CONTEXT_READ` + * or `SessionPoolInterface::CONTEXT_READWRITE`. If read/write is + * chosen, any snapshot options will be disregarded. If `$begin` + * is false, this option will be ignored. **Defaults to** + * `SessionPoolInterface::CONTEXT_READ`. + * } + * @codingStandardsIgnoreEnd + * @return Result + */ + public function execute($sql, array $options = []) + { + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READ); + + list($transactionOptions, $context) = $this->transactionSelector($options); + $options['transaction'] = $transactionOptions; + $options['transactionContext'] = $context; + + return $this->operation->execute($session, $sql, $options); + } + + /** + * Lookup rows in a table. + * + * Example: + * ``` + * $keySet = new KeySet([ + * 'keys' => [1337] + * ]); + * + * $columns = ['ID', 'title', 'content']; + * + * $result = $database->read('Posts', $keySet, $columns); + * ``` + * + * ``` + * // Execute a read and return a new Snapshot for further reads. + * $keySet = new KeySet([ + * 'keys' => [1337] + * ]); + * + * $columns = ['ID', 'title', 'content']; + * + * $result = $database->read('Posts', $keySet, $columns, [ + * 'begin' => true + * ]); + * + * $snapshot = $result->snapshot(); + * ``` + * + * ``` + * // Execute a read and return a new Transaction for further reads and writes. + * $keySet = new KeySet([ + * 'keys' => [1337] + * ]); + * + * $columns = ['ID', 'title', 'content']; + * + * $result = $database->read('Posts', $keySet, $columns, [ + * 'begin' => true, + * 'transactionType' => SessionPoolInterface::CONTEXT_READWRITE + * ]); + * + * $transaction = $result->transaction(); + * ``` + * + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.ReadRequest ReadRequest + * + * @codingStandardsIgnoreStart + * @param string $table The table name. + * @param KeySet $keySet The KeySet to select rows. + * @param array $columns A list of column names to return. + * @param array $options [optional] { + * Configuration Options. + * + * See [TransactionOptions](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.TransactionOptions) + * for detailed description of available transaction options. + * + * Please note that only one of `$strong`, `$minReadTimestamp`, + * `$maxStaleness`, `$readTimestamp` or `$exactStaleness` may be set in + * a request. + * + * @type string $index The name of an index on the table. + * @type int $offset The number of rows to offset results by. + * @type int $limit The number of results to return. + * @type bool $returnReadTimestamp If true, the Cloud Spanner-selected + * read timestamp is included in the Transaction message that + * describes the transaction. + * @type bool $strong Read at a timestamp where all previously committed + * transactions are visible. + * @type Timestamp $minReadTimestamp Execute reads at a timestamp >= the + * given timestamp. Only available in single-use transactions. + * @type Duration $maxStaleness Read data at a timestamp >= NOW - the + * given timestamp. Only available in single-use transactions. + * @type Timestamp $readTimestamp Executes all reads at the given + * timestamp. + * @type Duration $exactStaleness Represents a number of seconds. Executes + * all reads at a timestamp that is $exactStaleness old. + * @type bool $begin If true, will begin a new transaction. If a + * read/write transaction is desired, set the value of + * $transactionType. If a transaction or snapshot is created, it + * will be returned as `$result->transaction()` or + * `$result->snapshot()`. **Defaults to** `false`. + * @type string $transactionType One of `SessionPoolInterface::CONTEXT_READ` + * or `SessionPoolInterface::CONTEXT_READWRITE`. If read/write is + * chosen, any snapshot options will be disregarded. If `$begin` + * is false, this option will be ignored. **Defaults to** + * `SessionPoolInterface::CONTEXT_READ`. + * } + * @codingStandardsIgnoreEnd + * @return Result + */ + public function read($table, KeySet $keySet, array $columns, array $options = []) + { + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READ); + + list($transactionOptions, $context) = $this->transactionSelector($options); + $options['transaction'] = $transactionOptions; + $options['transactionContext'] = $context; + + return $this->operation->read($session, $table, $keySet, $columns, $options); + } + + /** + * Retrieve a session from the session pool. + * + * @param string $context The session context. + * @return Session + */ + private function selectSession($context = SessionPoolInterface::CONTEXT_READ) + { + return $this->sessionPool->session( + $this->instance->name(), + $this->name, + $context + ); + } + + /** + * Convert the simple database name to a fully qualified name. + * + * @return string + */ + private function fullyQualifiedDatabaseName() + { + return GrpcSpannerClient::formatDatabaseName( + $this->projectId, + $this->instance->name(), + $this->name + ); + } + + /** + * Represent the class in a more readable and digestable fashion. + * + * @access private + * @codeCoverageIgnore + */ + public function __debugInfo() + { + return [ + 'connection' => get_class($this->connection), + 'projectId' => $this->projectId, + 'name' => $this->name, + 'instance' => $this->instance, + 'sessionPool' => $this->sessionPool, + ]; + } +} diff --git a/src/Spanner/Date.php b/src/Spanner/Date.php new file mode 100644 index 000000000000..8822c2bd9f7a --- /dev/null +++ b/src/Spanner/Date.php @@ -0,0 +1,110 @@ +date(new \DateTimeImmutable('1995-02-04')); + * ``` + * + * ``` + * // Date objects can be cast to strings for easy display. + * echo (string) $date; + * ``` + */ +class Date implements ValueInterface +{ + const FORMAT = 'Y-m-d'; + + /** + * @var \DateTimeInterface + */ + protected $value; + + /** + * @param \DateTimeInterface $value The date value. + */ + public function __construct(\DateTimeInterface $value) + { + $this->value = $value; + } + + /** + * Get the underlying `\DateTimeInterface` implementation. + * + * Example: + * ``` + * $dateTime = $date->get(); + * ``` + * + * @return \DateTimeInterface + */ + public function get() + { + return $this->value; + } + + /** + * Get the type. + * + * Example: + * ``` + * echo $date->type(); + * ``` + * + * @return string + */ + public function type() + { + return ValueMapper::TYPE_DATE; + } + + /** + * Format the value as a string. + * + * Example: + * ``` + * echo $date->formatAsString(); + * ``` + * + * @return string + */ + public function formatAsString() + { + return $this->value->format(self::FORMAT); + } + + /** + * Format the value as a string. + * + * @return string + * @access private + */ + public function __toString() + { + return $this->formatAsString(); + } +} diff --git a/src/Spanner/Duration.php b/src/Spanner/Duration.php new file mode 100644 index 000000000000..c867d55fe99d --- /dev/null +++ b/src/Spanner/Duration.php @@ -0,0 +1,121 @@ +duration($seconds, $nanoSeconds); + * ``` + * + * ``` + * // Duration objects can be cast to json-encoded strings. + * echo (string) $duration; + * ``` + */ +class Duration implements ValueInterface +{ + const TYPE = 'DURATION'; + + /** + * @var int + */ + private $seconds; + + /** + * @var int + */ + private $nanos; + + /** + * @param int $seconds The number of seconds in the duration. + * @param int $nanos The number of nanoseconds in the duration. + */ + public function __construct($seconds, $nanos = 0) + { + $this->seconds = $seconds; + $this->nanos = $nanos; + } + + /** + * Get the duration + * + * Example: + * ``` + * $res = $duration->get(); + * ``` + * + * @return array + */ + public function get() + { + return [ + 'seconds' => $this->seconds, + 'nanos' => $this->nanos + ]; + } + + /** + * Get the type. + * + * Example: + * ``` + * echo $duration->type(); + * ``` + * + * @return string + */ + public function type() + { + return self::TYPE; + } + + /** + * Format the value as a string. + * + * Example: + * ``` + * echo $duration->formatAsString(); + * ``` + * + * @return string + */ + public function formatAsString() + { + return json_encode($this->get()); + } + + /** + * Format the value as a string. + * + * @return string + * @access private + */ + public function __toString() + { + return $this->formatAsString(); + } +} diff --git a/src/Spanner/Instance.php b/src/Spanner/Instance.php new file mode 100644 index 000000000000..44e029983010 --- /dev/null +++ b/src/Spanner/Instance.php @@ -0,0 +1,472 @@ +spanner(); + * + * $instance = $spanner->instance('my-instance'); + * ``` + */ +class Instance +{ + use ArrayTrait; + use LROTrait; + + const STATE_READY = State::READY; + const STATE_CREATING = State::CREATING; + + /** + * @var ConnectionInterface + */ + private $connection; + + /** + * @var SessionPool; + */ + private $sessionPool; + + /** + * @var LongRunningConnectionInterface + */ + private $lroConnection; + + /** + * @var array + */ + private $lroCallables; + + /** + * @var string + */ + private $projectId; + + /** + * @var string + */ + private $name; + + /** + * @var bool + */ + private $returnInt64AsObject; + + /** + * @var array + */ + private $info; + + /** + * @var Iam + */ + private $iam; + + /** + * Create an object representing a Google Cloud Spanner instance. + * + * @param ConnectionInterface $connection The connection to the + * Google Cloud Spanner Admin API. + * @param SessionPoolInterface $sessionPool The session pool implementation. + * @param LongRunningConnectionInterface $lroConnection An implementation + * mapping to methods which handle LRO resolution in the service. + * @param array $lroCallables + * @param string $projectId The project ID. + * @param string $name The instance name. + * @param bool $returnInt64AsObject If true, 64 bit integers will be + * returned as a {@see Google\Cloud\Core\Int64} object for 32 bit platform + * compatibility. **Defaults to** false. + * @param array $info [optional] A representation of the instance object. + */ + public function __construct( + ConnectionInterface $connection, + SessionPoolInterface $sessionPool, + LongRunningConnectionInterface $lroConnection, + array $lroCallables, + $projectId, + $name, + $returnInt64AsObject = false, + array $info = [] + ) { + $this->connection = $connection; + $this->sessionPool = $sessionPool; + $this->lroConnection = $lroConnection; + $this->lroCallables = $lroCallables; + $this->projectId = $projectId; + $this->name = $name; + $this->returnInt64AsObject = $returnInt64AsObject; + $this->info = $info; + } + + /** + * Return the instance name. + * + * Example: + * ``` + * $name = $instance->name(); + * ``` + * + * @return string + */ + public function name() + { + return $this->name; + } + + /** + * Return the service representation of the instance. + * + * This method may require a service call. + * + * Example: + * ``` + * $info = $instance->info(); + * echo $info['nodeCount']; + * ``` + * + * @param array $options [optional] Configuration options. + * @return array + */ + public function info(array $options = []) + { + if (!$this->info) { + $this->reload($options); + } + + return $this->info; + } + + /** + * Check if the instance exists. + * + * This method requires a service call. + * + * Example: + * ``` + * if ($instance->exists()) { + * echo 'Instance exists!'; + * } + * ``` + * + * @param array $options [optional] Configuration options. + * @return array + */ + public function exists(array $options = []) + { + try { + $this->reload($options = []); + } catch (NotFoundException $e) { + return false; + } + + return true; + } + + /** + * Fetch a fresh representation of the instance from the service. + * + * Example: + * ``` + * $info = $instance->reload(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.admin.instance.v1#google.spanner.admin.instance.v1.GetInstanceRequest GetInstanceRequest + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] Configuration options. + * @return array + */ + public function reload(array $options = []) + { + $this->info = $this->connection->getInstance($options + [ + 'name' => $this->fullyQualifiedInstanceName() + ]); + + return $this->info; + } + + /** + * Return the instance state. + * + * When instances are created or updated, they may take some time before + * they are ready for use. This method allows for checking whether an + * instance is ready. + * + * Example: + * ``` + * if ($instance->state() === Instance::STATE_READY) { + * echo 'Instance is ready!'; + * } + * ``` + * + * @param array $options [optional] Configuration options. + * @return string + */ + public function state(array $options = []) + { + $info = $this->info($options); + + return (isset($info['state'])) + ? $info['state'] + : null; + } + + /** + * Update the instance + * + * Example: + * ``` + * $instance->update([ + * 'displayName' => 'My Instance', + * 'nodeCount' => 4 + * ]); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.admin.instance.v1#updateinstancerequest UpdateInstanceRequest + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] { + * Configuration options + * + * @type string $displayName The descriptive name for this instance as + * it appears in UIs. **Defaults to** the value of $name. + * @type int $nodeCount The number of nodes allocated to this instance. + * **Defaults to** `1`. + * @type array $labels For more information, see + * [Using labels to organize Google Cloud Platform resources](https://goo.gl/xmQnxf). + * } + * @return LongRunningOperation + * @throws \InvalidArgumentException + */ + public function update(array $options = []) + { + $info = $this->info($options); + + $options += [ + 'displayName' => $info['displayName'], + 'nodeCount' => (isset($info['nodeCount'])) ? $info['nodeCount'] : null, + 'labels' => (isset($info['labels'])) + ? $info['labels'] + : [] + ]; + + $operation = $this->connection->updateInstance([ + 'name' => $this->fullyQualifiedInstanceName(), + ] + $options); + + return $this->lro($this->lroConnection, $operation['name'], $this->lroCallables); + } + + /** + * Delete the instance, any databases in the instance, and all data. + * + * Example: + * ``` + * $instance->delete(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.admin.instance.v1#deleteinstancerequest DeleteInstanceRequest + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] Configuration options. + * @return void + */ + public function delete(array $options = []) + { + return $this->connection->deleteInstance($options + [ + 'name' => $this->fullyQualifiedInstanceName() + ]); + } + + /** + * Create a Database + * + * Example: + * ``` + * $database = $instance->createDatabase('my-database'); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.admin.database.v1#createdatabaserequest CreateDatabaseRequest + * @codingStandardsIgnoreEnd + * + * @param string $name The database name. + * @param array $options [optional] { + * Configuration Options + * + * @type array $statements Additional DDL statements. + * } + * @return LongRunningOperation + */ + public function createDatabase($name, array $options = []) + { + $options += [ + 'statements' => [], + ]; + + $statement = sprintf('CREATE DATABASE `%s`', $name); + + $operation = $this->connection->createDatabase([ + 'instance' => $this->fullyQualifiedInstanceName(), + 'createStatement' => $statement, + 'extraStatements' => $options['statements'] + ]); + + return $this->lro($this->lroConnection, $operation['name'], $this->lroCallables); + } + + /** + * Lazily instantiate a database object + * + * Example: + * ``` + * $database = $instance->database('my-database'); + * ``` + * + * @param string $name The database name + * @return Database + */ + public function database($name) + { + return new Database( + $this->connection, + $this, + $this->sessionPool, + $this->lroConnection, + $this->lroCallables, + $this->projectId, + $name, + $this->returnInt64AsObject + ); + } + + /** + * List databases in an instance + * + * Example: + * ``` + * $databases = $instance->databases(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.database.v1#listdatabasesrequest ListDatabasesRequest + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] { + * Configuration options. + * + * @type int $pageSize Maximum number of results to return per + * request. + * @type int $resultLimit Limit the number of results returned in total. + * **Defaults to** `0` (return all results). + * @type string $pageToken A previously-returned page token used to + * resume the loading of results from a specific point. + * } + * @return ItemIterator + */ + public function databases(array $options = []) + { + $resultLimit = $this->pluck('resultLimit', $options, false); + return new ItemIterator( + new PageIterator( + function (array $database) { + $name = DatabaseAdminClient::parseDatabaseFromDatabaseName($database['name']); + return $this->database($name); + }, + [$this->connection, 'listDatabases'], + $options + ['instance' => $this->fullyQualifiedInstanceName()], + [ + 'itemsKey' => 'databases', + 'resultLimit' => $resultLimit + ] + ) + ); + } + + /** + * Manage the instance IAM policy + * + * Example: + * ``` + * $iam = $instance->iam(); + * ``` + * + * @return Iam + */ + public function iam() + { + if (!$this->iam) { + $this->iam = new Iam( + new IamInstance($this->connection), + $this->fullyQualifiedInstanceName() + ); + } + + return $this->iam; + } + + /** + * Convert the simple instance name to a fully qualified name. + * + * @return string + */ + private function fullyQualifiedInstanceName() + { + return InstanceAdminClient::formatInstanceName($this->projectId, $this->name); + } + + /** + * Represent the class in a more readable and digestable fashion. + * + * @access private + * @codeCoverageIgnore + */ + public function __debugInfo() + { + return [ + 'connection' => get_class($this->connection), + 'projectId' => $this->projectId, + 'name' => $this->name, + 'info' => $this->info + ]; + } +} diff --git a/src/Spanner/KeyRange.php b/src/Spanner/KeyRange.php new file mode 100644 index 000000000000..ecd45a1f9529 --- /dev/null +++ b/src/Spanner/KeyRange.php @@ -0,0 +1,237 @@ +spanner(); + * + * // Create a KeyRange for all people named Bob, born in 1969. + * $start = $spanner->date(new \DateTime('1969-01-01')); + * $end = $spanner->date(new \DateTime('1969-12-31')); + * + * $range = $spanner->keyRange([ + * 'startType' => KeyRange::TYPE_CLOSED, + * 'start' => ['Bob', $start], + * 'endType' => KeyRange::TYPE_CLOSED, + * 'end' => ['Bob', $end] + * ]); + * ``` + * + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.KeyRange KeyRange + */ +class KeyRange +{ + const TYPE_OPEN = 'open'; + const TYPE_CLOSED = 'closed'; + + /** + * @var string + */ + private $startType; + + /** + * @var array + */ + private $start; + + /** + * @var string + */ + private $endType; + + /** + * @var array + */ + private $end; + + /** + * @var array + */ + private $definition = [ + self::TYPE_OPEN => [ + 'start' => 'startOpen', + 'end' => 'endOpen' + ], + self::TYPE_CLOSED => [ + 'start' => 'startClosed', + 'end' => 'endClosed' + ] + ]; + + /** + * Create a KeyRange. + * + * @param array $options [optional] { + * Configuration Options. + * + * @type string $startType Either "open" or "closed". Use constants + * `KeyRange::TYPE_OPEN` and `KeyRange::TYPE_CLOSED` for + * guaranteed correctness. + * @type array $start The key with which to start the range. + * @type string $endType Either "open" or "closed". Use constants + * `KeyRange::TYPE_OPEN` and `KeyRange::TYPE_CLOSED` for + * guaranteed correctness. + * @type array $end The key with which to end the range. + * } + */ + public function __construct(array $options = []) + { + $options += [ + 'startType' => null, + 'start' => null, + 'endType' => null, + 'end' => null + ]; + + $this->startType = $options['startType']; + $this->start = $options['start']; + $this->endType = $options['endType']; + $this->end = $options['end']; + } + + /** + * Get the range start. + * + * Example: + * ``` + * $start = $range->start(); + * ``` + * + * @return array|null + */ + public function start() + { + return $this->start; + } + + /** + * Set the range start. + * + * Example: + * ``` + * $range->setStart(KeyRange::TYPE_OPEN, ['Bob']); + * ``` + * + * @param string $type Either "open" or "closed". Use constants + * `KeyRange::TYPE_OPEN` and `KeyRange::TYPE_CLOSED` for guaranteed + * correctness. + * @param array $start The start of the key range. + * @return void + */ + public function setStart($type, array $start) + { + if (!in_array($type, array_keys($this->definition))) { + throw new \InvalidArgumentException(sprintf( + 'Invalid KeyRange type. Allowed values are %s', + implode(', ', array_keys($this->definition)) + )); + } + + $rangeKey = $this->definition[$type]['start']; + + $this->startType = $rangeKey; + $this->start = $start; + } + + /** + * Get the range end. + * + * Example: + * ``` + * $end = $range->end(); + * ``` + * + * @return array + */ + public function end() + { + return $this->end; + } + + /** + * Set the range end. + * + * Example: + * ``` + * $range->setEnd(KeyRange::TYPE_CLOSED, ['Jill']); + * ``` + * + * @param string $type Either "open" or "closed". Use constants + * `KeyRange::TYPE_OPEN` and `KeyRange::TYPE_CLOSED` for guaranteed + * correctness. + * @param array $end The end of the key range. + * @return void + */ + public function setEnd($type, array $end) + { + if (!in_array($type, array_keys($this->definition))) { + throw new \InvalidArgumentException(sprintf( + 'Invalid KeyRange type. Allowed values are %s', + implode(', ', array_keys($this->definition)) + )); + } + + $rangeKey = $this->definition[$type]['end']; + + $this->endType = $rangeKey; + $this->end = $end; + } + + /** + * Get the start and end types + * + * Example: + * ``` + * $types = $range->types(); + * ``` + * + * @return array An array containing `start` and `end` keys. + */ + public function types() + { + return [ + 'start' => $this->startType, + 'end' => $this->endType + ]; + } + + /** + * Returns an API-compliant representation of a KeyRange. + * + * @return array + * @access private + */ + public function keyRangeObject() + { + if (!$this->start || !$this->end) { + throw new \BadMethodCallException('Key Range must supply a start and an end'); + } + + return [ + $this->startType => $this->start, + $this->endType => $this->end + ]; + } +} diff --git a/src/Spanner/KeySet.php b/src/Spanner/KeySet.php new file mode 100644 index 000000000000..1ee4259f9bac --- /dev/null +++ b/src/Spanner/KeySet.php @@ -0,0 +1,239 @@ +keySet(); + * ``` + * + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#keyset KeySet + */ +class KeySet +{ + use ValidateTrait; + + /** + * @var array + */ + private $keys; + + /** + * @var KeyRange[] + */ + private $ranges; + + /** + * @var bool + */ + private $all; + + /** + * Create a KeySet. + * + * @param array $options [optional] { + * @type array $keys A list of specific keys. Entries in keys should + * have exactly as many elements as there are columns in the + * primary or index key with which this KeySet is used. + * @type KeyRange[] $ranges A list of Key Ranges. + * @type bool $all If true, KeySet will match all keys in a table. + * **Defaults to** `false`. + * } + */ + public function __construct(array $options = []) + { + $options += [ + 'keys' => [], + 'ranges' => [], + 'all' => false + ]; + + $this->validateBatch($options['ranges'], KeyRange::class); + + $this->keys = $options['keys']; + $this->ranges = $options['ranges']; + $this->all = (bool) $options['all']; + } + + /** + * Fetch the KeyRanges + * + * Example: + * ``` + * $ranges = $keySet->ranges(); + * ``` + * + * @return KeyRange[] + */ + public function ranges() + { + return $this->ranges; + } + + + /** + * Add a single KeyRange. + * + * Example: + * ``` + * $range = new KeyRange(); + * $keySet->addRange($range); + * ``` + * + * @param KeyRange $range A KeyRange instance. + * @return void + */ + public function addRange(KeyRange $range) + { + $this->ranges[] = $range; + } + + /** + * Set the KeySet's KeyRanges. + * + * Any existing KeyRanges will be overridden. + * + * Example: + * ``` + * $range = new KeyRange(); + * $keySet->setRanges([$range]); + * ``` + * + * @param KeyRange[] $ranges An array of KeyRange objects. + * @return void + */ + public function setRanges(array $ranges) + { + $this->validateBatch($ranges, KeyRange::class); + + $this->ranges = $ranges; + } + + /** + * Fetch the keys. + * + * Example: + * ``` + * $keys = $keySet->keys(); + * ``` + * + * @return mixed[] + */ + public function keys() + { + return $this->keys; + } + + /** + * Add a single key. + * + * A Key should have exactly as many elements as there are columns in the + * primary or index key with which this KeySet is used. + * + * Example: + * ``` + * $keySet->addKey('Bob'); + * ``` + * + * @param mixed $key The Key to add. + * @return void + */ + public function addKey($key) + { + $this->keys[] = $key; + } + + /** + * Set the KeySet keys. + * + * Any existing keys will be overridden. + * + * Example: + * ``` + * $keySet->setKeys(['Bob', 'Jill']); + * ``` + * + * @param mixed[] $keys + * @return void + */ + public function setKeys(array $keys) + { + $this->keys = $keys; + } + + /** + * Get the value of Match All. + * + * Example: + * ``` + * if ($keySet->matchAll()) { + * echo "All keys will match"; + * } + * ``` + * + * @return bool + */ + public function matchAll() + { + return $this->all; + } + + /** + * Choose whether the KeySet should match all keys in a table. + * + * Example: + * ``` + * $keySet->setMatchAll(true); + * ``` + * + * @param bool $all If true, all keys in a table will be matched. + * @return void + */ + public function setMatchAll($all) + { + $this->all = (bool) $all; + } + + /** + * Format a KeySet object for use in the Spanner API. + * + * @access private + */ + public function keySetObject() + { + $ranges = []; + foreach ($this->ranges as $range) { + $ranges[] = $range->keyRangeObject(); + } + + return [ + 'keys' => $this->keys, + 'ranges' => $ranges, + 'all' => $this->all + ]; + } +} diff --git a/src/Spanner/LICENSE b/src/Spanner/LICENSE new file mode 100644 index 000000000000..8f71f43fee3f --- /dev/null +++ b/src/Spanner/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/src/Spanner/Operation.php b/src/Spanner/Operation.php new file mode 100644 index 000000000000..b242972b5ea1 --- /dev/null +++ b/src/Spanner/Operation.php @@ -0,0 +1,382 @@ +connection = $connection; + $this->mapper = new ValueMapper($returnInt64AsObject); + } + + /** + * Create a formatted mutation. + * + * @param string $operation The operation type. + * @param string $table The table name. + * @param array $mutation The mutation data, represented as a set of + * key/value pairs. + * @return array + */ + public function mutation($operation, $table, $mutation) + { + $mutation = $this->arrayFilterRemoveNull($mutation); + + return [ + $operation => [ + 'table' => $table, + 'columns' => array_keys($mutation), + 'values' => $this->mapper->encodeValuesAsSimpleType(array_values($mutation)) + ] + ]; + } + + /** + * Create a formatted delete mutation. + * + * @param string $table The table name. + * @param KeySet $keySet The keys to delete. + * @return array + */ + public function deleteMutation($table, KeySet $keySet) + { + return [ + self::OP_DELETE => [ + 'table' => $table, + 'keySet' => $this->flattenKeySet($keySet), + ] + ]; + } + + /** + * Commit all enqueued mutations. + * + * @codingStandardsIgnoreStart + * @param Session $session The session ID to use for the commit. + * @param Transaction $transaction The transaction to commit. + * @param array $options [optional] { + * Configuration options. + * + * @type string $transactionId The ID of the transaction. + * } + * @return Timestamp The commit Timestamp. + */ + public function commit(Session $session, array $mutations, array $options = []) + { + $options += [ + 'transactionId' => null + ]; + + $res = $this->connection->commit($this->arrayFilterRemoveNull([ + 'mutations' => $mutations, + 'session' => $session->name() + ]) + $options); + + return $this->mapper->createTimestampWithNanos($res['commitTimestamp']); + } + + /** + * Rollback a Transaction + * + * @param Session $session The session to use for the rollback. + * Note that the session MUST be the same one in which the + * transaction was created. + * @param string $transactionId The transaction to roll back. + * @param array $options [optional] Configuration Options. + * @return void + */ + public function rollback(Session $session, $transactionId, array $options = []) + { + return $this->connection->rollback([ + 'transactionId' => $transactionId, + 'session' => $session->name() + ] + $options); + } + + /** + * Run a query + * + * @param Session $session The session to use to execute the SQL. + * @param string $sql The query string. + * @param array $options [optional] Configuration options. + * @return array + */ + public function execute(Session $session, $sql, array $options = []) + { + $options += [ + 'parameters' => [], + 'transactionContext' => null + ]; + + $parameters = $this->pluck('parameters', $options); + $options += $this->mapper->formatParamsForExecuteSql($parameters); + + $context = $this->pluck('transactionContext', $options); + + $res = $this->connection->executeSql([ + 'sql' => $sql, + 'session' => $session->name() + ] + $options); + + return $this->createResult($session, $res, $context); + } + + /** + * Lookup rows in a database. + * + * @param Session $session The session in which to read data. + * @param string $table The table name. + * @param KeySet $keySet The KeySet to select rows. + * @param array $columns A list of column names to return. + * @param array $options [optional] { + * Configuration Options. + * + * @type string $index The name of an index on the table. + * @type int $offset The number of rows to offset results by. + * @type int $limit The number of results to return. + * } + * @return Result + */ + public function read(Session $session, $table, KeySet $keySet, array $columns, array $options = []) + { + $options += [ + 'index' => null, + 'limit' => null, + 'offset' => null, + 'transactionContext' => null + ]; + + $context = $this->pluck('transactionContext', $options); + $res = $this->connection->read([ + 'table' => $table, + 'session' => $session->name(), + 'columns' => $columns, + 'keySet' => $this->flattenKeySet($keySet) + ] + $options); + + return $this->createResult($session, $res, $context); + } + + /** + * Create a read/write transaction. + * + * @todo if a transaction is already available on the session, get it instead + * of starting a new one? + * + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest + * + * @param Session $session The session to start the transaction in. + * @param array $options [optional] Configuration options. + * @return Transaction + */ + public function transaction(Session $session, array $options = []) + { + $res = $this->beginTransaction($session, $options); + return $this->createTransaction($session, $res); + } + + /** + * Create a read-only snapshot transaction. + * + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest + * + * @param Session $session The session to start the snapshot in. + * @param array $options [optional] Configuration options. + * @return Snapshot + */ + public function snapshot(Session $session, array $options = []) + { + $res = $this->beginTransaction($session, $options); + + return $this->createSnapshot($session, $res); + } + + /** + * Execute a service call to begin a transaction or snapshot. + * + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest + * + * @param Session $session The session to start the snapshot in. + * @param array $options [optional] Configuration options. + * @return array + */ + private function beginTransaction(Session $session, array $options = []) + { + $options += [ + 'transactionOptions' => [] + ]; + + return $this->connection->beginTransaction($options + [ + 'session' => $session->name(), + ]); + } + + /** + * Create a Transaction instance from a response object. + * + * @param Session $session The session the transaction belongs to. + * @param array $res The transaction response. + * @return Transaction + */ + private function createTransaction(Session $session, array $res) + { + return new Transaction($this, $session, $res['id']); + } + + /** + * Create a Snapshot instance from a response object. + * + * @param Session $session The session the snapshot belongs to. + * @param array $res The snapshot response. + * @return Snapshot + */ + private function createSnapshot(Session $session, array $res) + { + $timestamp = null; + if (isset($res['readTimestamp'])) { + $timestamp = $this->mapper->createTimestampWithNanos($res['readTimestamp']); + } + + return new Snapshot($this, $session, $res['id'], $timestamp); + } + + /** + * Transform a service read or executeSql response to a friendly result. + * + * @codingStandardsIgnoreStart + * @param Session $session The current session. + * @param array $res [ResultSet](https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.ResultSet) + * @param string $transactionContext + * @return Result + * @codingStandardsIgnoreEnd + */ + private function createResult(Session $session, array $res, $transactionContext) + { + $columns = isset($res['metadata']['rowType']['fields']) + ? $res['metadata']['rowType']['fields'] + : []; + + $rows = []; + if (isset($res['rows'])) { + foreach ($res['rows'] as $row) { + $rows[] = $this->mapper->decodeValues($columns, $row); + } + } + + $options = []; + if (isset($res['metadata']['transaction']['id'])) { + if ($transactionContext === SessionPoolInterface::CONTEXT_READ) { + $options['snapshot'] = $this->createSnapshot($session, $res['metadata']['transaction']); + } else { + $options['transaction'] = $this->createTransaction($session, $res['metadata']['transaction']); + } + } + + return new Result($res, $rows, $options); + } + + /** + * Convert a KeySet object to an API-ready array. + * + * @param KeySet $keySet The keySet object. + * @return array [KeySet](https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#keyset) + */ + private function flattenKeySet(KeySet $keySet) + { + $keyRanges = $keySet->ranges(); + if ($keyRanges) { + $ranges = []; + foreach ($keyRanges as $range) { + $types = $range->types(); + + $start = $range->start(); + $range->setStart($types['start'], $this->mapper->encodeValuesAsSimpleType($start)); + + $end = $range->end(); + $range->setEnd($types['end'], $this->mapper->encodeValuesAsSimpleType($end)); + + $ranges[] = $range; + } + + $keySet->setRanges($ranges); + } + + $keys = $keySet->keySetObject(); + if (!empty($keys['keys'])) { + $keys['keys'] = $this->mapper->encodeValuesAsSimpleType($keys['keys']); + } + + return $this->arrayFilterRemoveNull($keys); + } + + /** + * Represent the class in a more readable and digestable fashion. + * + * @access private + * @codeCoverageIgnore + */ + public function __debugInfo() + { + return [ + 'connection' => get_class($this->connection), + ]; + } +} diff --git a/src/Spanner/README.md b/src/Spanner/README.md new file mode 100644 index 000000000000..de583242a7c3 --- /dev/null +++ b/src/Spanner/README.md @@ -0,0 +1,16 @@ +# Google Cloud PHP Spanner + +> Idiomatic PHP client for [Cloud Spanner](https://cloud.google.com/spanner/). + +* [Homepage](http://googlecloudplatform.github.io/google-cloud-php) +* [API documentation](http://googlecloudplatform.github.io/google-cloud-php/#/docs/cloud-spanner/latest/spanner/spannerclient) + +**NOTE:** This repository is part of [Google Cloud PHP](https://github.com/googlecloudplatform/google-cloud-php). Any +support requests, bug reports, or development contributions should be directed to +that project. + +## Installation + +``` +$ composer require google/cloud-spanner +``` diff --git a/src/Spanner/Result.php b/src/Spanner/Result.php new file mode 100644 index 000000000000..c168438ce963 --- /dev/null +++ b/src/Spanner/Result.php @@ -0,0 +1,203 @@ +spanner(); + * $database = $spanner->connect('my-instance', 'my-database'); + * + * $result = $database->execute('SELECT * FROM Posts'); + * ``` + * + * @see https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.ResultSet ResultSet + */ +class Result implements \IteratorAggregate +{ + /** + * @var array + */ + private $result; + + /** + * @var array + */ + private $rows; + + /** + * @var array + */ + private $options; + + /** + * @param array $result The query or read result. + * @param array $rows The rows, formatted and decoded. + * @param array $options Additional result options and info. + */ + public function __construct(array $result, array $rows, array $options = []) + { + $this->result = $result; + $this->rows = $rows; + $this->options = $options; + } + + /** + * Return result metadata + * + * Example: + * ``` + * $metadata = $result->metadata(); + * ``` + * + * @codingStandardsIgnoreStart + * @return array [ResultSetMetadata](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.ResultSetMetadata). + * @codingStandardsIgnoreEnd + */ + public function metadata() + { + return $this->result['metadata']; + } + + /** + * Return the formatted and decoded rows. + * + * Example: + * ``` + * $rows = $result->rows(); + * ``` + * + * @return array|null + */ + public function rows() + { + return $this->rows; + } + + /** + * Return the first row, or null. + * + * Useful when selecting a single row. + * + * Example: + * ``` + * $row = $result->firstRow(); + * ``` + * + * @return array|null + */ + public function firstRow() + { + return (isset($this->rows[0])) + ? $this->rows[0] + : null; + } + + /** + * Get the query plan and execution statistics for the query that produced + * this result set. + * + * Stats are not returned by default. + * + * Example: + * ``` + * $stats = $result->stats(); + * ``` + * + * ``` + * // Executing a query with stats returned. + * $res = $database->execute('SELECT * FROM Posts', [ + * 'queryMode' => 'PROFILE' + * ]); + * ``` + * + * @codingStandardsIgnoreStart + * @return array|null [ResultSetStats](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.ResultSetStats). + * @codingStandardsIgnoreEnd + */ + public function stats() + { + return (isset($this->result['stats'])) + ? $this->result['stats'] + : null; + } + + /** + * Returns a transaction which was begun in the read or execute, if one exists. + * + * Example: + * ``` + * $transaction = $result->transaction(); + * ``` + * + * @return Transaction|null + */ + public function transaction() + { + return (isset($this->options['transaction'])) + ? $this->options['transaction'] + : null; + } + + /** + * Returns a snapshot which was begun in the read or execute, if one exists. + * + * Example: + * ``` + * $snapshot = $result->snapshot(); + * ``` + * + * @return Snapshot|null + */ + public function snapshot() + { + return (isset($this->options['snapshot'])) + ? $this->options['snapshot'] + : null; + } + + /** + * Get the entire query or read response as given by the API. + * + * Example: + * ``` + * $info = $result->info(); + * ``` + * + * @codingStandardsIgnoreStart + * @return array [ResultSet](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.ResultSet). + * @codingStandardsIgnoreEnd + */ + public function info() + { + return $this->result; + } + + /** + * @access private + */ + public function getIterator() + { + return new \ArrayIterator($this->rows); + } +} diff --git a/src/Spanner/Session/Session.php b/src/Spanner/Session/Session.php new file mode 100644 index 000000000000..0ecf8f3d3a00 --- /dev/null +++ b/src/Spanner/Session/Session.php @@ -0,0 +1,145 @@ +connection = $connection; + $this->projectId = $projectId; + $this->instance = $instance; + $this->database = $database; + $this->name = $name; + } + + /** + * Return info on the session + * + * @return array An array containing the `projectId`, `instance`, `database` and session `name` keys. + */ + public function info() + { + return [ + 'projectId' => $this->projectId, + 'instance' => $this->instance, + 'database' => $this->database, + 'name' => $this->name + ]; + } + + /** + * Check if the session exists. + * + * @param array $options [optional] Configuration options. + * @return array + */ + public function exists(array $options = []) + { + try { + $this->connection->getSession($options + [ + 'name' => $this->name() + ]); + + return true; + } catch (NotFoundException $e) { + return false; + } + } + + /** + * Delete the session. + * + * @param array $options [optional] Configuration options. + * @return void + */ + public function delete(array $options = []) + { + return $this->connection->deleteSession($options + [ + 'name' => $this->name() + ]); + } + + /** + * Format the constituent parts of a session name into a fully qualified session name. + * + * @return string + */ + public function name() + { + return SpannerClient::formatSessionName( + $this->projectId, + $this->instance, + $this->database, + $this->name + ); + } + + /** + * @access private + */ + public function __debugInfo() + { + return [ + 'connection' => get_class($this->connection), + 'projectId' => $this->projectId, + 'instance' => $this->instance, + 'database' => $this->database, + 'name' => $this->name, + ]; + } +} diff --git a/src/Spanner/Session/SessionClient.php b/src/Spanner/Session/SessionClient.php new file mode 100644 index 000000000000..1642e9519c3a --- /dev/null +++ b/src/Spanner/Session/SessionClient.php @@ -0,0 +1,104 @@ +connection = $connection; + $this->projectId = $projectId; + } + + /** + * Create a new session in the given instance and database. + * + * @param string $instance The simple instance name. + * @param string $database The simple database name. + * @param array $options [optional] Configuration options. + * @return Session|null If the operation succeeded, a Session object will be returned, + * otherwise null. + */ + public function create($instance, $database, array $options = []) + { + $res = $this->connection->createSession($options + [ + 'database' => SpannerClient::formatDatabaseName($this->projectId, $instance, $database) + ]); + + $session = null; + if (isset($res['name'])) { + $session = $this->session($res['name']); + } + + return $session; + } + + /** + * Get a Session + * + * @param string $sessionName The Session name. + * @return Session + */ + public function session($sessionName) + { + return new Session( + $this->connection, + $this->projectId, + SpannerClient::parseInstanceFromSessionName($sessionName), + SpannerClient::parseDatabaseFromSessionName($sessionName), + SpannerClient::parseSessionFromSessionName($sessionName) + ); + } + + /** + * @access private + */ + public function __debugInfo() + { + return [ + 'connection' => get_class($this->connection), + 'projectId' => $this->projectId + ]; + } +} diff --git a/src/Spanner/Session/SessionPool.php b/src/Spanner/Session/SessionPool.php new file mode 100644 index 000000000000..2c96d8b12e88 --- /dev/null +++ b/src/Spanner/Session/SessionPool.php @@ -0,0 +1,63 @@ +sessionClient = $sessionClient; + } + + /** + * @access private + */ + public function addSession(Session $session) + { + $this->sessions[] = $sessions; + } + + /** + * @access private + */ + public function session($instance, $database, $context, array $options = []) + { + return array_rand($this->sessions); + } + + /** + * @access private + */ + public function refreshSessions() + { + // send a request from each session to keep it alive. + } +} diff --git a/dev/src/SetStubConnectionTrait.php b/src/Spanner/Session/SessionPoolInterface.php similarity index 66% rename from dev/src/SetStubConnectionTrait.php rename to src/Spanner/Session/SessionPoolInterface.php index 3ffb3d5b606a..2aa4943d981c 100644 --- a/dev/src/SetStubConnectionTrait.php +++ b/src/Spanner/Session/SessionPoolInterface.php @@ -15,12 +15,18 @@ * limitations under the License. */ -namespace Google\Cloud\Dev; +namespace Google\Cloud\Spanner\Session; -trait SetStubConnectionTrait +/** + * Describes a session pool. + */ +interface SessionPoolInterface { - public function setConnection($conn) - { - $this->connection = $conn; - } + const CONTEXT_READ = 'r'; + const CONTEXT_READWRITE = 'rw'; + + /** + * Get a session from the pool + */ + public function session($instance, $database, $context, array $options = []); } diff --git a/src/Spanner/Session/SimpleSessionPool.php b/src/Spanner/Session/SimpleSessionPool.php new file mode 100644 index 000000000000..d1e756f7b9cf --- /dev/null +++ b/src/Spanner/Session/SimpleSessionPool.php @@ -0,0 +1,54 @@ +sessionClient = $sessionClient; + } + + /** + * @access private + */ + public function session($instance, $database, $mode, array $options = []) + { + if (!isset($this->sessions[$instance.$database.$mode])) { + $this->sessions[$instance.$database] = $this->sessionClient->create($instance, $database, $options); + } + + return $this->sessions[$instance.$database]; + } +} diff --git a/src/Spanner/Snapshot.php b/src/Spanner/Snapshot.php new file mode 100644 index 000000000000..09fd1e2e5af3 --- /dev/null +++ b/src/Spanner/Snapshot.php @@ -0,0 +1,146 @@ +connect('my-instance', 'my-database'); + * $snapshot = $database->snapshot(); + * ``` + * + * @method execute() { + * Run a query. + * + * Example: + * ``` + * $result = $snapshot->execute( + * 'SELECT * FROM Users WHERE id = @userId', + * [ + * 'parameters' => [ + * 'userId' => 1 + * ] + * ] + * ); + * ``` + * + * @param string $sql The query string to execute. + * @param array $options [optional] { + * Configuration options. + * + * @type array $parameters A key/value array of Query Parameters, where + * the key is represented in the query string prefixed by a `@` + * symbol. + * } + * @return Result + * } + * @method read() { + * Lookup rows in a table. + * + * Example: + * ``` + * $keySet = new KeySet([ + * 'keys' => [10] + * ]); + * + * $columns = ['ID', 'title', 'content']; + * + * $result = $snapshot->read('Posts', $keySet, $columns); + * ``` + * + * @param string $table The table name. + * @param KeySet $keySet The KeySet to select rows. + * @param array $columns A list of column names to return. + * @param array $options [optional] { + * Configuration Options. + * + * @type string $index The name of an index on the table. + * @type int $offset The number of rows to offset results by. + * @type int $limit The number of results to return. + * } + * @return Result + * } + * @method id() { + * Retrieve the Transaction ID. + * + * Example: + * ``` + * $id = $snapshot->id(); + * ``` + * + * @return string + * } + */ +class Snapshot +{ + use TransactionReadTrait; + + /** + * @var Timestamp + */ + private $readTimestamp; + + /** + * @param Operation $operation The Operation instance. + * @param Session $session The session to use for spanner interactions. + * @param string $transactionId The Transaction ID. + * @param Timestamp $readTimestamp [optional] The read timestamp. + */ + public function __construct( + Operation $operation, + Session $session, + $transactionId, + Timestamp $readTimestamp = null + ) { + $this->operation = $operation; + $this->session = $session; + $this->transactionId = $transactionId; + $this->readTimestamp = $readTimestamp; + $this->context = SessionPoolInterface::CONTEXT_READWRITE; + } + + /** + * Retrieve the Read Timestamp. + * + * For snapshot read-only transactions, the read timestamp chosen for the + * transaction. + * + * Example: + * ``` + * $timestamp = $snapshot->readTimestamp(); + * ``` + * + * @return Timestamp + */ + public function readTimestamp() + { + return $this->readTimestamp; + } +} diff --git a/src/Spanner/SpannerClient.php b/src/Spanner/SpannerClient.php new file mode 100644 index 000000000000..3c4034bfac67 --- /dev/null +++ b/src/Spanner/SpannerClient.php @@ -0,0 +1,550 @@ + [ + self::FULL_CONTROL_SCOPE, + self::ADMIN_SCOPE + ], + 'returnInt64AsObject' => false + ]; + + $this->connection = new Grpc($this->configureAuthentication($config)); + $this->lroConnection = new LongRunningConnection($this->connection); + + $this->sessionClient = new SessionClient($this->connection, $this->projectId); + $this->sessionPool = new SimpleSessionPool($this->sessionClient); + + $this->returnInt64AsObject = $config['returnInt64AsObject']; + + $this->lroCallables = [ + [ + 'typeUrl' => 'type.googleapis.com/google.spanner.admin.instance.v1.UpdateInstanceMetadata', + 'callable' => function ($instance) { + $name = InstanceAdminClient::parseInstanceFromInstanceName($instance['name']); + return $this->instance($name, $instance); + } + ], [ + 'typeUrl' => 'type.googleapis.com/google.spanner.admin.database.v1.CreateDatabaseMetadata', + 'callable' => function ($database) { + $instanceName = DatabaseAdminClient::parseInstanceFromDatabaseName($database['name']); + $databaseName = DatabaseAdminClient::parseDatabaseFromDatabaseName($database['name']); + + $instance = $this->instance($instanceName); + return $instance->database($databaseName); + } + ], [ + 'typeUrl' => 'type.googleapis.com/google.spanner.admin.instance.v1.CreateInstanceMetadata', + 'callable' => function ($instance) { + $name = InstanceAdminClient::parseInstanceFromInstanceName($instance['name']); + return $this->instance($name, $instance); + } + ] + ]; + } + + /** + * List all available configurations. + * + * Example: + * ``` + * $configurations = $spanner->configurations(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.instance.v1#google.spanner.admin.instance.v1.ListInstanceConfigsRequest ListInstanceConfigsRequest + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] { + * Configuration Options. + * + * @type int $pageSize Maximum number of results to return per + * request. + * @type int $resultLimit Limit the number of results returned in total. + * **Defaults to** `0` (return all results). + * @type string $pageToken A previously-returned page token used to + * resume the loading of results from a specific point. + * } + * @return ItemIterator + */ + public function configurations(array $options = []) + { + $resultLimit = $this->pluck('resultLimit', $options, false) ?: 0; + + return new ItemIterator( + new PageIterator( + function (array $config) { + $name = InstanceAdminClient::parseInstanceConfigFromInstanceConfigName($config['name']); + return $this->configuration($name, $config); + }, + [$this->connection, 'listConfigs'], + ['projectId' => InstanceAdminClient::formatProjectName($this->projectId)] + $options, + [ + 'itemsKey' => 'instanceConfigs', + 'resultLimit' => $resultLimit + ] + ) + ); + } + + /** + * Get a configuration by its name. + * + * NOTE: This method does not execute a service request and does not verify + * the existence of the given configuration. Unless you know with certainty + * that the configuration exists, it is advised that you use + * {@see Google\Cloud\Spanner\Configuration::exists()} to verify existence + * before attempting to use the configuration. + * + * Example: + * ``` + * $configuration = $spanner->configuration($configurationName); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.instance.v1#getinstanceconfigrequest GetInstanceConfigRequest + * @codingStandardsIgnoreEnd + * + * @param string $name The Configuration name. + * @param array $config [optional] The configuration details. + * @return Configuration + */ + public function configuration($name, array $config = []) + { + return new Configuration($this->connection, $this->projectId, $name, $config); + } + + /** + * Create a new instance. + * + * Example: + * ``` + * $operation = $spanner->createInstance($configuration, 'my-instance'); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.instance.v1#createinstancerequest CreateInstanceRequest + * + * @param Configuration $config The configuration to use + * @param string $name The instance name + * @param array $options [optional] { + * Configuration options + * + * @type string $displayName **Defaults to** the value of $name. + * @type int $nodeCount **Defaults to** `1`. + * @type array $labels For more information, see + * [Using labels to organize Google Cloud Platform resources](https://cloudplatform.googleblog.com/2015/10/using-labels-to-organize-Google-Cloud-Platform-resources.html). + * } + * @return LongRunningOperation + * @codingStandardsIgnoreEnd + */ + public function createInstance(Configuration $config, $name, array $options = []) + { + $options += [ + 'displayName' => $name, + 'nodeCount' => self::DEFAULT_NODE_COUNT, + 'labels' => [], + 'operationName' => null, + ]; + + // This must always be set to CREATING, so overwrite anything else. + $options['state'] = State::CREATING; + + $operation = $this->connection->createInstance([ + 'instanceId' => $name, + 'name' => InstanceAdminClient::formatInstanceName($this->projectId, $name), + 'projectId' => InstanceAdminClient::formatProjectName($this->projectId), + 'config' => InstanceAdminClient::formatInstanceConfigName($this->projectId, $config->name()) + ] + $options); + + return $this->lro($this->lroConnection, $operation['name'], $this->lroCallables); + } + + /** + * Lazily instantiate an instance. + * + * Example: + * ``` + * $instance = $spanner->instance('my-instance'); + * ``` + * + * @param string $name The instance name + * @return Instance + */ + public function instance($name, array $instance = []) + { + return new Instance( + $this->connection, + $this->sessionPool, + $this->lroConnection, + $this->lroCallables, + $this->projectId, + $name, + $this->returnInt64AsObject, + $instance + ); + } + + /** + * List instances in the project + * + * Example: + * ``` + * $instances = $spanner->instances(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.instance.v1#listinstancesrequest ListInstancesRequest + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] { + * Configuration options + * + * @type string $filter An expression for filtering the results of the + * request. + * @type int $pageSize Maximum number of results to return per + * request. + * @type int $resultLimit Limit the number of results returned in total. + * **Defaults to** `0` (return all results). + * @type string $pageToken A previously-returned page token used to + * resume the loading of results from a specific point. + * } + * @return ItemIterator + */ + public function instances(array $options = []) + { + $options += [ + 'filter' => null + ]; + + $resultLimit = $this->pluck('resultLimit', $options, false); + return new ItemIterator( + new PageIterator( + function (array $instance) { + $name = InstanceAdminClient::parseInstanceFromInstanceName($instance['name']); + return $this->instance($name, $instance); + }, + [$this->connection, 'listInstances'], + ['projectId' => InstanceAdminClient::formatProjectName($this->projectId)] + $options, + [ + 'itemsKey' => 'instances', + 'resultLimit' => $resultLimit + ] + ) + ); + } + + /** + * Connect to a database to run queries or mutations. + * + * Example: + * ``` + * $database = $spanner->connect('my-instance', 'my-application-database'); + * ``` + * + * @param Instance|string $instance The instance object or instance name. + * @param string $name The database name. + * @return Database + */ + public function connect($instance, $name) + { + if (is_string($instance)) { + $instance = $this->instance($instance); + } + + $database = $instance->database($name); + + return $database; + } + + /** + * Create a new KeySet object + * + * Example: + * ``` + * $keySet = $spanner->keySet(); + * ``` + * + * ``` + * // Create a keyset to return all rows in a table. + * $keySet = $spanner->keySet(['all' => true]); + * ``` + * + * @param array $options [optional] { + * Configuration Options + * + * @type array $keys A list of keys + * @type KeyRange[] $ranges A list of key ranges + * @type bool $all Whether to include all keys in a table + * } + * @return KeySet + */ + public function keySet(array $options = []) + { + return new KeySet($options); + } + + /** + * Create a new KeyRange object + * + * Example: + * ``` + * $range = $spanner->keyRange(); + * ``` + * + * ``` + * // Ranges can be created with all data supplied. + * $range = $spanner->keyRange([ + * 'startType' => KeyRange::TYPE_OPEN, + * 'start' => ['Bob'], + * 'endType' => KeyRange::TYPE_OPEN, + * 'end' => ['Jill'] + * ]); + * ``` + * + * @param array $options [optional] { + * Configuration Options. + * + * @type string $startType Either "open" or "closed". Use constants + * `KeyRange::TYPE_OPEN` and `KeyRange::TYPE_CLOSED` for + * guaranteed correctness. + * @type array $start The key with which to start the range. + * @type string $endType Either "open" or "closed". Use constants + * `KeyRange::TYPE_OPEN` and `KeyRange::TYPE_CLOSED` for + * guaranteed correctness. + * @type array $end The key with which to end the range. + * } + * @return KeyRange + */ + public function keyRange(array $options = []) + { + return new KeyRange($options); + } + + /** + * Create a Bytes object. + * + * Example: + * ``` + * $bytes = $spanner->bytes('hello world'); + * ``` + * + * @param string|resource|StreamInterface $value The bytes value. + * @return Bytes + */ + public function bytes($bytes) + { + return new Bytes($bytes); + } + + /** + * Create a Date object. + * + * Example: + * ``` + * $date = $spanner->date(new \DateTime('1995-02-04')); + * ``` + * + * @param \DateTimeInterface $value The date value. + * @return Date + */ + public function date(\DateTimeInterface $date) + { + return new Date($date); + } + + /** + * Create a Timestamp object. + * + * Example: + * ``` + * $timestamp = $spanner->timestamp(new \DateTime('2003-02-05 11:15:02.421827Z')); + * ``` + * + * @param \DateTimeInterface $value The timestamp value. + * @param int $nanoSeconds [optional] The number of nanoseconds in the timestamp. + * @return Timestamp + */ + public function timestamp(\DateTimeInterface $timestamp, $nanoSeconds = null) + { + return new Timestamp($timestamp, $nanoSeconds); + } + + /** + * Create an Int64 object. This can be used to work with 64 bit integers as + * a string value while on a 32 bit platform. + * + * Example: + * ``` + * $int64 = $spanner->int64('9223372036854775807'); + * ``` + * + * @param string $value + * @return Int64 + */ + public function int64($value) + { + return new Int64($value); + } + + /** + * Create a Duration object. + * + * Example: + * ``` + * $duration = $spanner->duration(100, 00001); + * ``` + * + * @param int $seconds The number of seconds in the duration. + * @param int $nanos [optional] The number of nanoseconds in the duration. + * **Defaults to** `0`. + * @return Duration + */ + public function duration($seconds, $nanos = 0) + { + return new Duration($seconds, $nanos); + } + + /** + * Get the session client + * + * Example: + * ``` + * $sessionClient = $spanner->sessionClient(); + * ``` + * + * @return SessionClient + */ + public function sessionClient() + { + return $this->sessionClient; + } + + /** + * Resume a Long Running Operation + * + * Example: + * ``` + * $operation = $spanner->resumeOperation($operationName); + * ``` + * + * @param string $operationName The Long Running Operation name. + * @return LongRunningOperation + */ + public function resumeOperation($operationName) + { + return $this->lro($this->lroConnection, $operationName, $this->lroCallables); + } +} diff --git a/src/Spanner/Timestamp.php b/src/Spanner/Timestamp.php new file mode 100644 index 000000000000..61ee9014c572 --- /dev/null +++ b/src/Spanner/Timestamp.php @@ -0,0 +1,129 @@ +timestamp(new \DateTime('2003-02-05 11:15:02.421827Z')); + * ``` + * + * ``` + * // Timestamps can be cast to strings. + * echo (string) $timestamp; + * ``` + */ +class Timestamp implements ValueInterface +{ + const FORMAT = 'Y-m-d\TH:i:s.u\Z'; + const FORMAT_INTERPOLATE = 'Y-m-d\TH:i:s.%\s\Z'; + + /** + * @var \DateTimeInterface + */ + private $value; + + /** + * @var int + */ + private $nanoSeconds; + + /** + * @param \DateTimeInterface $value The timestamp value. + * @param int $nanoSeconds [optional] The number of nanoseconds in the timestamp. + */ + public function __construct(\DateTimeInterface $value, $nanoSeconds = null) + { + $this->value = $value; + $this->nanoSeconds = $nanoSeconds ?: (int) $this->value->format('u'); + } + + /** + * Get the underlying `\DateTimeInterface` implementation. + * + * Please note that nanosecond precision is not present in this method. + * + * Example: + * ``` + * $dateTime = $timestamp->get(); + * ``` + * + * @return \DateTimeInterface + */ + public function get() + { + return $this->value; + } + + /** + * Get the type. + * + * Example: + * ``` + * $type = $timestamp->type(); + * ``` + * + * @return string + */ + public function type() + { + return ValueMapper::TYPE_TIMESTAMP; + } + + /** + * Format the value as a string. + * + * This method retains nanosecond precision, if available. + * + * Example: + * ``` + * $value = $timestamp->formatAsString(); + * ``` + * + * @return string + */ + public function formatAsString() + { + $this->value->setTimezone(new \DateTimeZone('UTC')); + $ns = str_pad((string) $this->nanoSeconds, 6, '0', STR_PAD_LEFT); + return sprintf($this->value->format(self::FORMAT_INTERPOLATE), $ns); + } + + /** + * Format the value as a string. + * + * @return string + * @access private + */ + public function __toString() + { + return $this->formatAsString(); + } +} diff --git a/src/Spanner/Transaction.php b/src/Spanner/Transaction.php new file mode 100644 index 000000000000..957bf91daf6e --- /dev/null +++ b/src/Spanner/Transaction.php @@ -0,0 +1,465 @@ +spanner(); + * + * $database = $spanner->connect('my-instance', 'my-database'); + * + * $database->runTransaction(function (Transaction $t) { + * // do stuff. + * + * $t->commit(); + * }); + * ``` + * + * ``` + * // Get a transaction to manage manually. + * $transaction = $database->transaction(); + * ``` + * + * @method execute() { + * Run a query. + * + * Example: + * ``` + * $result = $transaction->execute( + * 'SELECT * FROM Users WHERE id = @userId', + * [ + * 'parameters' => [ + * 'userId' => 1 + * ] + * ] + * ); + * ``` + * + * @param string $sql The query string to execute. + * @param array $options [optional] { + * Configuration options. + * + * @type array $parameters A key/value array of Query Parameters, where + * the key is represented in the query string prefixed by a `@` + * symbol. + * } + * @return Result + * } + * @method read() { + * Lookup rows in a table. + * + * Example: + * ``` + * $keySet = new KeySet([ + * 'keys' => [10] + * ]); + * + * $columns = ['ID', 'title', 'content']; + * + * $result = $transaction->read('Posts', $keySet, $columns); + * ``` + * + * @param string $table The table name. + * @param KeySet $keySet The KeySet to select rows. + * @param array $columns A list of column names to return. + * @param array $options [optional] { + * Configuration Options. + * + * @type string $index The name of an index on the table. + * @type int $offset The number of rows to offset results by. + * @type int $limit The number of results to return. + * } + * @return Result + * } + * @method id() { + * Retrieve the Transaction ID. + * + * Example: + * ``` + * $id = $transaction->id(); + * ``` + * + * @return string + * } + */ +class Transaction +{ + use TransactionReadTrait; + + const STATE_ACTIVE = 0; + const STATE_ROLLED_BACK = 1; + const STATE_COMMITTED = 2; + + /** + * @var array + */ + private $mutations = []; + + /** + * @var int + */ + private $state = self::STATE_ACTIVE; + + /** + * @param Operation $operation The Operation instance. + * @param Session $session The session to use for spanner interactions. + * @param string $transactionId The Transaction ID. + */ + public function __construct( + Operation $operation, + Session $session, + $transactionId + ) { + $this->operation = $operation; + $this->session = $session; + $this->transactionId = $transactionId; + $this->context = SessionPoolInterface::CONTEXT_READWRITE; + } + + /** + * Enqueue an insert mutation. + * + * Example: + * ``` + * $transaction->insert('Posts', [ + * 'ID' => 10, + * 'title' => 'My New Post', + * 'content' => 'Hello World' + * ]); + * ``` + * + * @param string $table The table to insert into. + * @param array $data The data to insert. + * @return Transaction The transaction, to enable method chaining. + */ + public function insert($table, array $data) + { + return $this->insertBatch($table, [$data]); + } + + /** + * Enqueue one or more insert mutations. + * + * Example: + * ``` + * $transaction->insertBatch('Posts', [ + * [ + * 'ID' => 10, + * 'title' => 'My New Post', + * 'content' => 'Hello World' + * ] + * ]); + * ``` + * + * @param string $table The table to insert into. + * @param array $dataSet The data to insert. + * @return Transaction The transaction, to enable method chaining. + */ + public function insertBatch($table, array $dataSet) + { + $this->enqueue(Operation::OP_INSERT, $table, $dataSet); + + return $this; + } + + /** + * Enqueue an update mutation. + * + * Example: + * ``` + * $transaction->update('Posts', [ + * 'ID' => 10, + * 'title' => 'My New Post [Updated!]', + * 'content' => 'Modified Content' + * ]); + * ``` + * + * @param string $table The table to update. + * @param array $data The data to update. + * @return Transaction The transaction, to enable method chaining. + */ + public function update($table, array $data) + { + return $this->updateBatch($table, [$data]); + } + + /** + * Enqueue one or more update mutations. + * + * Example: + * ``` + * $transaction->updateBatch('Posts', [ + * [ + * 'ID' => 10, + * 'title' => 'My New Post [Updated!]', + * 'content' => 'Modified Content' + * ] + * ]); + * ``` + * + * @param string $table The table to update. + * @param array $dataSet The data to update. + * @return Transaction The transaction, to enable method chaining. + */ + public function updateBatch($table, array $dataSet) + { + $this->enqueue(Operation::OP_UPDATE, $table, $dataSet); + + return $this; + } + + /** + * Enqueue an insert or update mutation. + * + * Example: + * ``` + * $transaction->insertOrUpdate('Posts', [ + * 'ID' => 10, + * 'title' => 'My New Post', + * 'content' => 'Hello World' + * ]); + * ``` + * + * @param string $table The table to insert into or update. + * @param array $data The data to insert or update. + * @return Transaction The transaction, to enable method chaining. + */ + public function insertOrUpdate($table, array $data) + { + return $this->insertOrUpdateBatch($table, [$data]); + } + + /** + * Enqueue one or more insert or update mutations. + * + * Example: + * ``` + * $transaction->insertOrUpdateBatch('Posts', [ + * [ + * 'ID' => 10, + * 'title' => 'My New Post', + * 'content' => 'Hello World' + * ] + * ]); + * ``` + * + * @param string $table The table to insert into or update. + * @param array $dataSet The data to insert or update. + * @return Transaction The transaction, to enable method chaining. + */ + public function insertOrUpdateBatch($table, array $dataSet) + { + $this->enqueue(Operation::OP_INSERT_OR_UPDATE, $table, $dataSet); + + return $this; + } + + /** + * Enqueue an replace mutation. + * + * Example: + * ``` + * $transaction->replace('Posts', [ + * 'ID' => 10, + * 'title' => 'My New Post [Replaced]', + * 'content' => 'Hello Moon' + * ]); + * ``` + * + * @param string $table The table to replace into. + * @param array $data The data to replace. + * @return Transaction The transaction, to enable method chaining. + */ + public function replace($table, array $data) + { + return $this->replaceBatch($table, [$data]); + } + + /** + * Enqueue one or more replace mutations. + * + * Example: + * ``` + * $transaction->replaceBatch('Posts', [ + * [ + * 'ID' => 10, + * 'title' => 'My New Post [Replaced]', + * 'content' => 'Hello Moon' + * ] + * ]); + * ``` + * + * @param string $table The table to replace into. + * @param array $dataSet The data to replace. + * @return Transaction The transaction, to enable method chaining. + */ + public function replaceBatch($table, array $dataSet) + { + $this->enqueue(Operation::OP_REPLACE, $table, $dataSet); + + return $this; + } + + /** + * Enqueue an delete mutation. + * + * Example: + * ``` + * $keySet = new KeySet([ + * 'keys' => [10] + * ]); + * + * $transaction->delete('Posts', $keySet); + * ``` + * + * @param string $table The table to mutate. + * @param KeySet $keySet The KeySet to identify rows to delete. + * @return Transaction The transaction, to enable method chaining. + */ + public function delete($table, KeySet $keySet) + { + $this->enqueue(Operation::OP_DELETE, $table, [$keySet]); + + return $this; + } + + /** + * Roll back a transaction. + * + * Rolls back a transaction, releasing any locks it holds. It is a good idea + * to call this for any transaction that includes one or more Read or + * ExecuteSql requests and ultimately decides not to commit. + * + * This closes the transaction, preventing any future API calls inside it. + * + * Rollback will NOT error if the transaction is not found or was already aborted. + * + * Example: + * ``` + * $transaction->rollback(); + * ``` + * + * @param array $options [optional] Configuration Options. + * @return void + */ + public function rollback(array $options = []) + { + if ($this->state !== self::STATE_ACTIVE) { + throw new \RuntimeException('The transaction cannot be rolled back because it is not active'); + } + + $this->state = self::STATE_ROLLED_BACK; + + return $this->operation->rollback($this->session, $this->transactionId, $options); + } + + /** + * Commit and end the transaction. + * + * It is advised that transactions be run inside + * {@see Google\Cloud\Spanner\Database::runTransaction()} in order to take + * advantage of automated transaction retry in case of a transaction aborted + * error. + * + * Example: + * ``` + * $transaction->commit(); + * ``` + * + * @param array $options [optional] Configuration Options. + * @return Timestamp The commit timestamp. + * @throws \RuntimeException If the transaction is not active + * @throws \AbortedException If the commit is aborted for any reason. + */ + public function commit(array $options = []) + { + if ($this->state !== self::STATE_ACTIVE) { + throw new \RuntimeException('The transaction cannot be committed because it is not active'); + } + + $this->state = self::STATE_COMMITTED; + + $options['transactionId'] = $this->transactionId; + return $this->operation->commit($this->session, $this->mutations, $options); + } + + /** + * Retrieve the Transaction State. + * + * Will be one of `Transaction::STATE_ACTIVE`, + * `Transaction::STATE_COMMITTED`, or `Transaction::STATE_ROLLED_BACK`. + * + * Example: + * ``` + * $state = $transaction->state(); + * ``` + * + * @return int + */ + public function state() + { + return $this->state; + } + + /** + * Format, validate and enqueue mutations in the transaction. + * + * @param string $op The operation type. + * @param string $table The table name + * @param array $dataSet the mutations to enqueue + * @return void + */ + private function enqueue($op, $table, array $dataSet) + { + foreach ($dataSet as $data) { + if ($op === Operation::OP_DELETE) { + $this->mutations[] = $this->operation->deleteMutation($table, $data); + } else { + $this->mutations[] = $this->operation->mutation($op, $table, $data); + } + } + } +} diff --git a/src/Spanner/TransactionConfigurationTrait.php b/src/Spanner/TransactionConfigurationTrait.php new file mode 100644 index 000000000000..a5f5e61b4c61 --- /dev/null +++ b/src/Spanner/TransactionConfigurationTrait.php @@ -0,0 +1,155 @@ + false, + 'transactionType' => SessionPoolInterface::CONTEXT_READ, + 'transactionId' => null + ]; + + $type = null; + + $context = $this->pluck('transactionType', $options); + $id = $this->pluck('transactionId', $options); + if (!is_null($id)) { + $type = 'id'; + $transactionOptions = $id; + } elseif ($context === SessionPoolInterface::CONTEXT_READ) { + $transactionOptions = $this->configureSnapshotOptions($options); + } elseif ($context === SessionPoolInterface::CONTEXT_READWRITE) { + $transactionOptions = $this->configureTransactionOptions(); + } else { + throw new \BadMethodCallException(sprintf( + 'Invalid transaction context %s', + $context + )); + } + + $begin = $this->pluck('begin', $options); + if (is_null($type)) { + $type = ($begin) ? 'begin' : 'singleUse'; + } + + return [ + [$type => $transactionOptions], + $context + ]; + } + + private function configureTransactionOptions() + { + return [ + 'readWrite' => [] + ]; + } + + /** + * Create a Read Only single use transaction. + * + * @param array $options Configuration Options. + * @return array + */ + private function configureSnapshotOptions(array &$options) + { + $options += [ + 'returnReadTimestamp' => null, + 'strong' => null, + 'readTimestamp' => null, + 'exactStaleness' => null, + 'minReadTimestamp' => null, + 'maxStaleness' => null, + ]; + + $transactionOptions = [ + 'readOnly' => $this->arrayFilterRemoveNull([ + 'returnReadTimestamp' => $this->pluck('returnReadTimestamp', $options), + 'strong' => $this->pluck('strong', $options), + 'minReadTimestamp' => $this->pluck('minReadTimestamp', $options), + 'maxStaleness' => $this->pluck('maxStaleness', $options), + 'readTimestamp' => $this->pluck('readTimestamp', $options), + 'exactStaleness' => $this->pluck('exactStaleness', $options), + ]) + ]; + + if (empty($transactionOptions['readOnly'])) { + $transactionOptions['readOnly']['strong'] = true; + } + + $timestampFields = [ + 'minReadTimestamp', + 'readTimestamp' + ]; + + $durationFields = [ + 'exactStaleness', + 'maxStaleness' + ]; + + foreach ($timestampFields as $tsf) { + if (isset($transactionOptions['readOnly'][$tsf])) { + $field = $transactionOptions['readOnly'][$tsf]; + if (!($field instanceof Timestamp)) { + throw new \BadMethodCallException(sprintf( + 'Read Only Transaction Configuration Field %s must be an instance of Timestamp', + $tsf + )); + } + + $transactionOptions['readOnly'][$tsf] = $field->formatAsString(); + } + } + + foreach ($durationFields as $df) { + if (isset($transactionOptions['readOnly'][$df])) { + $field = $transactionOptions['readOnly'][$df]; + if (!($field instanceof Duration)) { + throw new \BadMethodCallException(sprintf( + 'Read Only Transaction Configuration Field %s must be an instance of Duration', + $df + )); + } + + $transactionOptions['readOnly'][$df] = $field->get(); + } + } + + return $transactionOptions; + } +} diff --git a/src/Spanner/TransactionReadTrait.php b/src/Spanner/TransactionReadTrait.php new file mode 100644 index 000000000000..b98b41f6725c --- /dev/null +++ b/src/Spanner/TransactionReadTrait.php @@ -0,0 +1,108 @@ +context; + $options['transactionId'] = $this->transactionId; + + list($transactionOptions, $context) = $this->transactionSelector($options); + $options['transaction'] = $transactionOptions; + $options['transactionContext'] = $context; + + return $this->operation->execute($this->session, $sql, $options); + } + + /** + * Lookup rows in a table. + * + * @param string $table The table name. + * @param KeySet $keySet The KeySet to select rows. + * @param array $columns A list of column names to return. + * @param array $options [optional] { + * Configuration Options. + * + * @type string $index The name of an index on the table. + * @type int $offset The number of rows to offset results by. + * @type int $limit The number of results to return. + * } + * @return Result + */ + public function read($table, KeySet $keySet, array $columns, array $options = []) + { + $options['transactionType'] = $this->context; + $options['transactionId'] = $this->transactionId; + + list($transactionOptions, $context) = $this->transactionSelector($options); + $options['transaction'] = $transactionOptions; + $options['transactionContext'] = $context; + + return $this->operation->read($this->session, $table, $keySet, $columns, $options); + } + + /** + * Retrieve the Transaction ID. + * + * @return string + */ + public function id() + { + return $this->transactionId; + } +} diff --git a/src/Spanner/V1/SpannerClient.php b/src/Spanner/V1/SpannerClient.php new file mode 100644 index 000000000000..a88419ad1441 --- /dev/null +++ b/src/Spanner/V1/SpannerClient.php @@ -0,0 +1,1180 @@ +createSession($formattedDatabase); + * } finally { + * $spannerClient->close(); + * } + * ``` + * + * Many parameters require resource names to be formatted in a particular way. To assist + * with these names, this class includes a format method for each type of name, and additionally + * a parse method to extract the individual identifiers contained within names that are + * returned. + */ +class SpannerClient +{ + /** + * The default address of the service. + */ + const SERVICE_ADDRESS = 'spanner.googleapis.com'; + + /** + * The default port of the service. + */ + const DEFAULT_SERVICE_PORT = 443; + + /** + * The default timeout for non-retrying methods. + */ + const DEFAULT_TIMEOUT_MILLIS = 30000; + + /** + * The name of the code generator, to be included in the agent header. + */ + const CODEGEN_NAME = 'gapic'; + + /** + * The code generator version, to be included in the agent header. + */ + const CODEGEN_VERSION = '0.1.0'; + + private static $databaseNameTemplate; + private static $sessionNameTemplate; + + private $grpcCredentialsHelper; + private $spannerStub; + private $scopes; + private $defaultCallSettings; + private $descriptors; + + /** + * Formats a string containing the fully-qualified path to represent + * a database resource. + */ + public static function formatDatabaseName($project, $instance, $database) + { + return self::getDatabaseNameTemplate()->render([ + 'project' => $project, + 'instance' => $instance, + 'database' => $database, + ]); + } + + /** + * Formats a string containing the fully-qualified path to represent + * a session resource. + */ + public static function formatSessionName($project, $instance, $database, $session) + { + return self::getSessionNameTemplate()->render([ + 'project' => $project, + 'instance' => $instance, + 'database' => $database, + 'session' => $session, + ]); + } + + /** + * Parses the project from the given fully-qualified path which + * represents a database resource. + */ + public static function parseProjectFromDatabaseName($databaseName) + { + return self::getDatabaseNameTemplate()->match($databaseName)['project']; + } + + /** + * Parses the instance from the given fully-qualified path which + * represents a database resource. + */ + public static function parseInstanceFromDatabaseName($databaseName) + { + return self::getDatabaseNameTemplate()->match($databaseName)['instance']; + } + + /** + * Parses the database from the given fully-qualified path which + * represents a database resource. + */ + public static function parseDatabaseFromDatabaseName($databaseName) + { + return self::getDatabaseNameTemplate()->match($databaseName)['database']; + } + + /** + * Parses the project from the given fully-qualified path which + * represents a session resource. + */ + public static function parseProjectFromSessionName($sessionName) + { + return self::getSessionNameTemplate()->match($sessionName)['project']; + } + + /** + * Parses the instance from the given fully-qualified path which + * represents a session resource. + */ + public static function parseInstanceFromSessionName($sessionName) + { + return self::getSessionNameTemplate()->match($sessionName)['instance']; + } + + /** + * Parses the database from the given fully-qualified path which + * represents a session resource. + */ + public static function parseDatabaseFromSessionName($sessionName) + { + return self::getSessionNameTemplate()->match($sessionName)['database']; + } + + /** + * Parses the session from the given fully-qualified path which + * represents a session resource. + */ + public static function parseSessionFromSessionName($sessionName) + { + return self::getSessionNameTemplate()->match($sessionName)['session']; + } + + private static function getDatabaseNameTemplate() + { + if (self::$databaseNameTemplate == null) { + self::$databaseNameTemplate = new PathTemplate('projects/{project}/instances/{instance}/databases/{database}'); + } + + return self::$databaseNameTemplate; + } + + private static function getSessionNameTemplate() + { + if (self::$sessionNameTemplate == null) { + self::$sessionNameTemplate = new PathTemplate('projects/{project}/instances/{instance}/databases/{database}/sessions/{session}'); + } + + return self::$sessionNameTemplate; + } + + private static function getGrpcStreamingDescriptors() + { + return [ + 'executeStreamingSql' => [ + 'grpcStreamingType' => 'ServerStreaming', + ], + 'streamingRead' => [ + 'grpcStreamingType' => 'ServerStreaming', + ], + ]; + } + + // TODO(garrettjones): add channel (when supported in gRPC) + /** + * Constructor. + * + * @param array $options { + * Optional. Options for configuring the service API wrapper. + * + * @type string $serviceAddress The domain name of the API remote host. + * Default 'spanner.googleapis.com'. + * @type mixed $port The port on which to connect to the remote host. Default 443. + * @type \Grpc\ChannelCredentials $sslCreds + * A `ChannelCredentials` for use with an SSL-enabled channel. + * Default: a credentials object returned from + * \Grpc\ChannelCredentials::createSsl() + * @type array $scopes A string array of scopes to use when acquiring credentials. + * Default the scopes for the Google Cloud Spanner API. + * @type array $retryingOverride + * An associative array of string => RetryOptions, where the keys + * are method names (e.g. 'createFoo'), that overrides default retrying + * settings. A value of null indicates that the method in question should + * not retry. + * @type int $timeoutMillis The timeout in milliseconds to use for calls + * that don't use retries. For calls that use retries, + * set the timeout in RetryOptions. + * Default: 30000 (30 seconds) + * @type string $appName The codename of the calling service. Default 'gax'. + * @type string $appVersion The version of the calling service. + * Default: the current version of GAX. + * @type \Google\Auth\CredentialsLoader $credentialsLoader + * A CredentialsLoader object created using the + * Google\Auth library. + * } + */ + public function __construct($options = []) + { + $defaultOptions = [ + 'serviceAddress' => self::SERVICE_ADDRESS, + 'port' => self::DEFAULT_SERVICE_PORT, + 'scopes' => [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/spanner.data', + ], + 'retryingOverride' => null, + 'timeoutMillis' => self::DEFAULT_TIMEOUT_MILLIS, + 'appName' => 'gax', + 'appVersion' => AgentHeaderDescriptor::getGaxVersion(), + ]; + $options = array_merge($defaultOptions, $options); + + $headerDescriptor = new AgentHeaderDescriptor([ + 'clientName' => $options['appName'], + 'clientVersion' => $options['appVersion'], + 'codeGenName' => self::CODEGEN_NAME, + 'codeGenVersion' => self::CODEGEN_VERSION, + 'gaxVersion' => AgentHeaderDescriptor::getGaxVersion(), + 'phpVersion' => phpversion(), + ]); + + $defaultDescriptors = ['headerDescriptor' => $headerDescriptor]; + $this->descriptors = [ + 'createSession' => $defaultDescriptors, + 'getSession' => $defaultDescriptors, + 'deleteSession' => $defaultDescriptors, + 'executeSql' => $defaultDescriptors, + 'executeStreamingSql' => $defaultDescriptors, + 'read' => $defaultDescriptors, + 'streamingRead' => $defaultDescriptors, + 'beginTransaction' => $defaultDescriptors, + 'commit' => $defaultDescriptors, + 'rollback' => $defaultDescriptors, + ]; + $grpcStreamingDescriptors = self::getGrpcStreamingDescriptors(); + foreach ($grpcStreamingDescriptors as $method => $grpcStreamingDescriptor) { + $this->descriptors[$method]['grpcStreamingDescriptor'] = $grpcStreamingDescriptor; + } + + $clientConfigJsonString = file_get_contents(__DIR__.'/resources/spanner_client_config.json'); + $clientConfig = json_decode($clientConfigJsonString, true); + $this->defaultCallSettings = + CallSettings::load( + 'google.spanner.v1.Spanner', + $clientConfig, + $options['retryingOverride'], + GrpcConstants::getStatusCodeNames(), + $options['timeoutMillis'] + ); + + $this->scopes = $options['scopes']; + + $createStubOptions = []; + if (array_key_exists('sslCreds', $options)) { + $createStubOptions['sslCreds'] = $options['sslCreds']; + } + $grpcCredentialsHelperOptions = array_diff_key($options, $defaultOptions); + $this->grpcCredentialsHelper = new GrpcCredentialsHelper($this->scopes, $grpcCredentialsHelperOptions); + + $createSpannerStubFunction = function ($hostname, $opts) { + return new SpannerGrpcClient($hostname, $opts); + }; + if (array_key_exists('createSpannerStubFunction', $options)) { + $createSpannerStubFunction = $options['createSpannerStubFunction']; + } + $this->spannerStub = $this->grpcCredentialsHelper->createStub( + $createSpannerStubFunction, + $options['serviceAddress'], + $options['port'], + $createStubOptions + ); + } + + /** + * Creates a new session. A session can be used to perform + * transactions that read and/or modify data in a Cloud Spanner database. + * Sessions are meant to be reused for many consecutive + * transactions. + * + * Sessions can only execute one transaction at a time. To execute + * multiple concurrent read-write/write-only transactions, create + * multiple sessions. Note that standalone reads and queries use a + * transaction internally, and count toward the one transaction + * limit. + * + * Cloud Spanner limits the number of sessions that can exist at any given + * time; thus, it is a good idea to delete idle and/or unneeded sessions. + * Aside from explicit deletes, Cloud Spanner can delete sessions for + * which no operations are sent for more than an hour, or due to + * internal errors. If a session is deleted, requests to it + * return `NOT_FOUND`. + * + * Idle sessions can be kept alive by sending a trivial SQL query + * periodically, e.g., `"SELECT 1"`. + * + * Sample code: + * ``` + * try { + * $spannerClient = new SpannerClient(); + * $formattedDatabase = SpannerClient::formatDatabaseName("[PROJECT]", "[INSTANCE]", "[DATABASE]"); + * $response = $spannerClient->createSession($formattedDatabase); + * } finally { + * $spannerClient->close(); + * } + * ``` + * + * @param string $database Required. The database in which the new session is created. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\spanner\v1\Session + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function createSession($database, $optionalArgs = []) + { + $request = new CreateSessionRequest(); + $request->setDatabase($database); + + $mergedSettings = $this->defaultCallSettings['createSession']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->spannerStub, + 'CreateSession', + $mergedSettings, + $this->descriptors['createSession'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Gets a session. Returns `NOT_FOUND` if the session does not exist. + * This is mainly useful for determining whether a session is still + * alive. + * + * Sample code: + * ``` + * try { + * $spannerClient = new SpannerClient(); + * $formattedName = SpannerClient::formatSessionName("[PROJECT]", "[INSTANCE]", "[DATABASE]", "[SESSION]"); + * $response = $spannerClient->getSession($formattedName); + * } finally { + * $spannerClient->close(); + * } + * ``` + * + * @param string $name Required. The name of the session to retrieve. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\spanner\v1\Session + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function getSession($name, $optionalArgs = []) + { + $request = new GetSessionRequest(); + $request->setName($name); + + $mergedSettings = $this->defaultCallSettings['getSession']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->spannerStub, + 'GetSession', + $mergedSettings, + $this->descriptors['getSession'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Ends a session, releasing server resources associated with it. + * + * Sample code: + * ``` + * try { + * $spannerClient = new SpannerClient(); + * $formattedName = SpannerClient::formatSessionName("[PROJECT]", "[INSTANCE]", "[DATABASE]", "[SESSION]"); + * $spannerClient->deleteSession($formattedName); + * } finally { + * $spannerClient->close(); + * } + * ``` + * + * @param string $name Required. The name of the session to delete. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function deleteSession($name, $optionalArgs = []) + { + $request = new DeleteSessionRequest(); + $request->setName($name); + + $mergedSettings = $this->defaultCallSettings['deleteSession']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->spannerStub, + 'DeleteSession', + $mergedSettings, + $this->descriptors['deleteSession'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Executes an SQL query, returning all rows in a single reply. This + * method cannot be used to return a result set larger than 10 MiB; + * if the query yields more data than that, the query fails with + * a `FAILED_PRECONDITION` error. + * + * Queries inside read-write transactions might return `ABORTED`. If + * this occurs, the application should restart the transaction from + * the beginning. See [Transaction][google.spanner.v1.Transaction] for more details. + * + * Larger result sets can be fetched in streaming fashion by calling + * [ExecuteStreamingSql][google.spanner.v1.Spanner.ExecuteStreamingSql] instead. + * + * Sample code: + * ``` + * try { + * $spannerClient = new SpannerClient(); + * $formattedSession = SpannerClient::formatSessionName("[PROJECT]", "[INSTANCE]", "[DATABASE]", "[SESSION]"); + * $sql = ""; + * $response = $spannerClient->executeSql($formattedSession, $sql); + * } finally { + * $spannerClient->close(); + * } + * ``` + * + * @param string $session Required. The session in which the SQL query should be performed. + * @param string $sql Required. The SQL query string. + * @param array $optionalArgs { + * Optional. + * + * @type TransactionSelector $transaction + * The transaction to use. If none is provided, the default is a + * temporary read-only transaction with strong concurrency. + * @type Struct $params + * The SQL query string can contain parameter placeholders. A parameter + * placeholder consists of `'@'` followed by the parameter + * name. Parameter names consist of any combination of letters, + * numbers, and underscores. + * + * Parameters can appear anywhere that a literal value is expected. The same + * parameter name can be used more than once, for example: + * `"WHERE id > @msg_id AND id < @msg_id + 100"` + * + * It is an error to execute an SQL query with unbound parameters. + * + * Parameter values are specified using `params`, which is a JSON + * object whose keys are parameter names, and whose values are the + * corresponding parameter values. + * @type array $paramTypes + * It is not always possible for Cloud Spanner to infer the right SQL type + * from a JSON value. For example, values of type `BYTES` and values + * of type `STRING` both appear in [params][google.spanner.v1.ExecuteSqlRequest.params] as JSON strings. + * + * In these cases, `param_types` can be used to specify the exact + * SQL type for some or all of the SQL query parameters. See the + * definition of [Type][google.spanner.v1.Type] for more information + * about SQL types. + * @type string $resumeToken + * If this request is resuming a previously interrupted SQL query + * execution, `resume_token` should be copied from the last + * [PartialResultSet][google.spanner.v1.PartialResultSet] yielded before the interruption. Doing this + * enables the new SQL query execution to resume where the last one left + * off. The rest of the request parameters must exactly match the + * request that yielded this token. + * @type QueryMode $queryMode + * Used to control the amount of debugging information returned in + * [ResultSetStats][google.spanner.v1.ResultSetStats]. + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\spanner\v1\ResultSet + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function executeSql($session, $sql, $optionalArgs = []) + { + $request = new ExecuteSqlRequest(); + $request->setSession($session); + $request->setSql($sql); + if (isset($optionalArgs['transaction'])) { + $request->setTransaction($optionalArgs['transaction']); + } + if (isset($optionalArgs['params'])) { + $request->setParams($optionalArgs['params']); + } + if (isset($optionalArgs['paramTypes'])) { + foreach ($optionalArgs['paramTypes'] as $key => $value) { + $request->addParamTypes((new ParamTypesEntry())->setKey($key)->setValue($value)); + } + } + if (isset($optionalArgs['resumeToken'])) { + $request->setResumeToken($optionalArgs['resumeToken']); + } + if (isset($optionalArgs['queryMode'])) { + $request->setQueryMode($optionalArgs['queryMode']); + } + + $mergedSettings = $this->defaultCallSettings['executeSql']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->spannerStub, + 'ExecuteSql', + $mergedSettings, + $this->descriptors['executeSql'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Like [ExecuteSql][google.spanner.v1.Spanner.ExecuteSql], except returns the result + * set as a stream. Unlike [ExecuteSql][google.spanner.v1.Spanner.ExecuteSql], there + * is no limit on the size of the returned result set. However, no + * individual row in the result set can exceed 100 MiB, and no + * column value can exceed 10 MiB. + * + * Sample code: + * ``` + * try { + * $spannerClient = new SpannerClient(); + * $formattedSession = SpannerClient::formatSessionName("[PROJECT]", "[INSTANCE]", "[DATABASE]", "[SESSION]"); + * $sql = ""; + * // Read all responses until the stream is complete + * $stream = $spannerClient->executeStreamingSql($formattedSession, $sql); + * foreach ($stream->readAll() as $element) { + * // doSomethingWith($element); + * } + * } finally { + * $spannerClient->close(); + * } + * ``` + * + * @param string $session Required. The session in which the SQL query should be performed. + * @param string $sql Required. The SQL query string. + * @param array $optionalArgs { + * Optional. + * + * @type TransactionSelector $transaction + * The transaction to use. If none is provided, the default is a + * temporary read-only transaction with strong concurrency. + * @type Struct $params + * The SQL query string can contain parameter placeholders. A parameter + * placeholder consists of `'@'` followed by the parameter + * name. Parameter names consist of any combination of letters, + * numbers, and underscores. + * + * Parameters can appear anywhere that a literal value is expected. The same + * parameter name can be used more than once, for example: + * `"WHERE id > @msg_id AND id < @msg_id + 100"` + * + * It is an error to execute an SQL query with unbound parameters. + * + * Parameter values are specified using `params`, which is a JSON + * object whose keys are parameter names, and whose values are the + * corresponding parameter values. + * @type array $paramTypes + * It is not always possible for Cloud Spanner to infer the right SQL type + * from a JSON value. For example, values of type `BYTES` and values + * of type `STRING` both appear in [params][google.spanner.v1.ExecuteSqlRequest.params] as JSON strings. + * + * In these cases, `param_types` can be used to specify the exact + * SQL type for some or all of the SQL query parameters. See the + * definition of [Type][google.spanner.v1.Type] for more information + * about SQL types. + * @type string $resumeToken + * If this request is resuming a previously interrupted SQL query + * execution, `resume_token` should be copied from the last + * [PartialResultSet][google.spanner.v1.PartialResultSet] yielded before the interruption. Doing this + * enables the new SQL query execution to resume where the last one left + * off. The rest of the request parameters must exactly match the + * request that yielded this token. + * @type QueryMode $queryMode + * Used to control the amount of debugging information returned in + * [ResultSetStats][google.spanner.v1.ResultSetStats]. + * @type int $timeoutMillis + * Timeout to use for this call. + * } + * + * @return \Google\GAX\ServerStreamingResponse + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function executeStreamingSql($session, $sql, $optionalArgs = []) + { + $request = new ExecuteSqlRequest(); + $request->setSession($session); + $request->setSql($sql); + if (isset($optionalArgs['transaction'])) { + $request->setTransaction($optionalArgs['transaction']); + } + if (isset($optionalArgs['params'])) { + $request->setParams($optionalArgs['params']); + } + if (isset($optionalArgs['paramTypes'])) { + foreach ($optionalArgs['paramTypes'] as $key => $value) { + $request->addParamTypes((new ParamTypesEntry())->setKey($key)->setValue($value)); + } + } + if (isset($optionalArgs['resumeToken'])) { + $request->setResumeToken($optionalArgs['resumeToken']); + } + if (isset($optionalArgs['queryMode'])) { + $request->setQueryMode($optionalArgs['queryMode']); + } + + $mergedSettings = $this->defaultCallSettings['executeStreamingSql']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->spannerStub, + 'ExecuteStreamingSql', + $mergedSettings, + $this->descriptors['executeStreamingSql'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Reads rows from the database using key lookups and scans, as a + * simple key/value style alternative to + * [ExecuteSql][google.spanner.v1.Spanner.ExecuteSql]. This method cannot be used to + * return a result set larger than 10 MiB; if the read matches more + * data than that, the read fails with a `FAILED_PRECONDITION` + * error. + * + * Reads inside read-write transactions might return `ABORTED`. If + * this occurs, the application should restart the transaction from + * the beginning. See [Transaction][google.spanner.v1.Transaction] for more details. + * + * Larger result sets can be yielded in streaming fashion by calling + * [StreamingRead][google.spanner.v1.Spanner.StreamingRead] instead. + * + * Sample code: + * ``` + * try { + * $spannerClient = new SpannerClient(); + * $formattedSession = SpannerClient::formatSessionName("[PROJECT]", "[INSTANCE]", "[DATABASE]", "[SESSION]"); + * $table = ""; + * $columns = []; + * $keySet = new KeySet(); + * $response = $spannerClient->read($formattedSession, $table, $columns, $keySet); + * } finally { + * $spannerClient->close(); + * } + * ``` + * + * @param string $session Required. The session in which the read should be performed. + * @param string $table Required. The name of the table in the database to be read. + * @param string[] $columns The columns of [table][google.spanner.v1.ReadRequest.table] to be returned for each row matching + * this request. + * @param KeySet $keySet Required. `key_set` identifies the rows to be yielded. `key_set` names the + * primary keys of the rows in [table][google.spanner.v1.ReadRequest.table] to be yielded, unless [index][google.spanner.v1.ReadRequest.index] + * is present. If [index][google.spanner.v1.ReadRequest.index] is present, then [key_set][google.spanner.v1.ReadRequest.key_set] instead names + * index keys in [index][google.spanner.v1.ReadRequest.index]. + * + * Rows are yielded in table primary key order (if [index][google.spanner.v1.ReadRequest.index] is empty) + * or index key order (if [index][google.spanner.v1.ReadRequest.index] is non-empty). + * + * It is not an error for the `key_set` to name rows that do not + * exist in the database. Read yields nothing for nonexistent rows. + * @param array $optionalArgs { + * Optional. + * + * @type TransactionSelector $transaction + * The transaction to use. If none is provided, the default is a + * temporary read-only transaction with strong concurrency. + * @type string $index + * If non-empty, the name of an index on [table][google.spanner.v1.ReadRequest.table]. This index is + * used instead of the table primary key when interpreting [key_set][google.spanner.v1.ReadRequest.key_set] + * and sorting result rows. See [key_set][google.spanner.v1.ReadRequest.key_set] for further information. + * @type int $limit + * If greater than zero, only the first `limit` rows are yielded. If `limit` + * is zero, the default is no limit. + * @type string $resumeToken + * If this request is resuming a previously interrupted read, + * `resume_token` should be copied from the last + * [PartialResultSet][google.spanner.v1.PartialResultSet] yielded before the interruption. Doing this + * enables the new read to resume where the last read left off. The + * rest of the request parameters must exactly match the request + * that yielded this token. + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\spanner\v1\ResultSet + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function read($session, $table, $columns, $keySet, $optionalArgs = []) + { + $request = new ReadRequest(); + $request->setSession($session); + $request->setTable($table); + foreach ($columns as $elem) { + $request->addColumns($elem); + } + $request->setKeySet($keySet); + if (isset($optionalArgs['transaction'])) { + $request->setTransaction($optionalArgs['transaction']); + } + if (isset($optionalArgs['index'])) { + $request->setIndex($optionalArgs['index']); + } + if (isset($optionalArgs['limit'])) { + $request->setLimit($optionalArgs['limit']); + } + if (isset($optionalArgs['resumeToken'])) { + $request->setResumeToken($optionalArgs['resumeToken']); + } + + $mergedSettings = $this->defaultCallSettings['read']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->spannerStub, + 'Read', + $mergedSettings, + $this->descriptors['read'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Like [Read][google.spanner.v1.Spanner.Read], except returns the result set as a + * stream. Unlike [Read][google.spanner.v1.Spanner.Read], there is no limit on the + * size of the returned result set. However, no individual row in + * the result set can exceed 100 MiB, and no column value can exceed + * 10 MiB. + * + * Sample code: + * ``` + * try { + * $spannerClient = new SpannerClient(); + * $formattedSession = SpannerClient::formatSessionName("[PROJECT]", "[INSTANCE]", "[DATABASE]", "[SESSION]"); + * $table = ""; + * $columns = []; + * $keySet = new KeySet(); + * // Read all responses until the stream is complete + * $stream = $spannerClient->streamingRead($formattedSession, $table, $columns, $keySet); + * foreach ($stream->readAll() as $element) { + * // doSomethingWith($element); + * } + * } finally { + * $spannerClient->close(); + * } + * ``` + * + * @param string $session Required. The session in which the read should be performed. + * @param string $table Required. The name of the table in the database to be read. + * @param string[] $columns The columns of [table][google.spanner.v1.ReadRequest.table] to be returned for each row matching + * this request. + * @param KeySet $keySet Required. `key_set` identifies the rows to be yielded. `key_set` names the + * primary keys of the rows in [table][google.spanner.v1.ReadRequest.table] to be yielded, unless [index][google.spanner.v1.ReadRequest.index] + * is present. If [index][google.spanner.v1.ReadRequest.index] is present, then [key_set][google.spanner.v1.ReadRequest.key_set] instead names + * index keys in [index][google.spanner.v1.ReadRequest.index]. + * + * Rows are yielded in table primary key order (if [index][google.spanner.v1.ReadRequest.index] is empty) + * or index key order (if [index][google.spanner.v1.ReadRequest.index] is non-empty). + * + * It is not an error for the `key_set` to name rows that do not + * exist in the database. Read yields nothing for nonexistent rows. + * @param array $optionalArgs { + * Optional. + * + * @type TransactionSelector $transaction + * The transaction to use. If none is provided, the default is a + * temporary read-only transaction with strong concurrency. + * @type string $index + * If non-empty, the name of an index on [table][google.spanner.v1.ReadRequest.table]. This index is + * used instead of the table primary key when interpreting [key_set][google.spanner.v1.ReadRequest.key_set] + * and sorting result rows. See [key_set][google.spanner.v1.ReadRequest.key_set] for further information. + * @type int $limit + * If greater than zero, only the first `limit` rows are yielded. If `limit` + * is zero, the default is no limit. + * @type string $resumeToken + * If this request is resuming a previously interrupted read, + * `resume_token` should be copied from the last + * [PartialResultSet][google.spanner.v1.PartialResultSet] yielded before the interruption. Doing this + * enables the new read to resume where the last read left off. The + * rest of the request parameters must exactly match the request + * that yielded this token. + * @type int $timeoutMillis + * Timeout to use for this call. + * } + * + * @return \Google\GAX\ServerStreamingResponse + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function streamingRead($session, $table, $columns, $keySet, $optionalArgs = []) + { + $request = new ReadRequest(); + $request->setSession($session); + $request->setTable($table); + foreach ($columns as $elem) { + $request->addColumns($elem); + } + $request->setKeySet($keySet); + if (isset($optionalArgs['transaction'])) { + $request->setTransaction($optionalArgs['transaction']); + } + if (isset($optionalArgs['index'])) { + $request->setIndex($optionalArgs['index']); + } + if (isset($optionalArgs['limit'])) { + $request->setLimit($optionalArgs['limit']); + } + if (isset($optionalArgs['resumeToken'])) { + $request->setResumeToken($optionalArgs['resumeToken']); + } + + $mergedSettings = $this->defaultCallSettings['streamingRead']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->spannerStub, + 'StreamingRead', + $mergedSettings, + $this->descriptors['streamingRead'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Begins a new transaction. This step can often be skipped: + * [Read][google.spanner.v1.Spanner.Read], [ExecuteSql][google.spanner.v1.Spanner.ExecuteSql] and + * [Commit][google.spanner.v1.Spanner.Commit] can begin a new transaction as a + * side-effect. + * + * Sample code: + * ``` + * try { + * $spannerClient = new SpannerClient(); + * $formattedSession = SpannerClient::formatSessionName("[PROJECT]", "[INSTANCE]", "[DATABASE]", "[SESSION]"); + * $options = new TransactionOptions(); + * $response = $spannerClient->beginTransaction($formattedSession, $options); + * } finally { + * $spannerClient->close(); + * } + * ``` + * + * @param string $session Required. The session in which the transaction runs. + * @param TransactionOptions $options Required. Options for the new transaction. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\spanner\v1\Transaction + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function beginTransaction($session, $options, $optionalArgs = []) + { + $request = new BeginTransactionRequest(); + $request->setSession($session); + $request->setOptions($options); + + $mergedSettings = $this->defaultCallSettings['beginTransaction']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->spannerStub, + 'BeginTransaction', + $mergedSettings, + $this->descriptors['beginTransaction'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Commits a transaction. The request includes the mutations to be + * applied to rows in the database. + * + * `Commit` might return an `ABORTED` error. This can occur at any time; + * commonly, the cause is conflicts with concurrent + * transactions. However, it can also happen for a variety of other + * reasons. If `Commit` returns `ABORTED`, the caller should re-attempt + * the transaction from the beginning, re-using the same session. + * + * Sample code: + * ``` + * try { + * $spannerClient = new SpannerClient(); + * $formattedSession = SpannerClient::formatSessionName("[PROJECT]", "[INSTANCE]", "[DATABASE]", "[SESSION]"); + * $mutations = []; + * $response = $spannerClient->commit($formattedSession, $mutations); + * } finally { + * $spannerClient->close(); + * } + * ``` + * + * @param string $session Required. The session in which the transaction to be committed is running. + * @param Mutation[] $mutations The mutations to be executed when this transaction commits. All + * mutations are applied atomically, in the order they appear in + * this list. + * @param array $optionalArgs { + * Optional. + * + * @type string $transactionId + * Commit a previously-started transaction. + * @type TransactionOptions $singleUseTransaction + * Execute mutations in a temporary transaction. Note that unlike + * commit of a previously-started transaction, commit with a + * temporary transaction is non-idempotent. That is, if the + * `CommitRequest` is sent to Cloud Spanner more than once (for + * instance, due to retries in the application, or in the + * transport library), it is possible that the mutations are + * executed more than once. If this is undesirable, use + * [BeginTransaction][google.spanner.v1.Spanner.BeginTransaction] and + * [Commit][google.spanner.v1.Spanner.Commit] instead. + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @return \google\spanner\v1\CommitResponse + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function commit($session, $mutations, $optionalArgs = []) + { + $request = new CommitRequest(); + $request->setSession($session); + foreach ($mutations as $elem) { + $request->addMutations($elem); + } + if (isset($optionalArgs['transactionId'])) { + $request->setTransactionId($optionalArgs['transactionId']); + } + if (isset($optionalArgs['singleUseTransaction'])) { + $request->setSingleUseTransaction($optionalArgs['singleUseTransaction']); + } + + $mergedSettings = $this->defaultCallSettings['commit']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->spannerStub, + 'Commit', + $mergedSettings, + $this->descriptors['commit'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Rolls back a transaction, releasing any locks it holds. It is a good + * idea to call this for any transaction that includes one or more + * [Read][google.spanner.v1.Spanner.Read] or [ExecuteSql][google.spanner.v1.Spanner.ExecuteSql] requests and + * ultimately decides not to commit. + * + * `Rollback` returns `OK` if it successfully aborts the transaction, the + * transaction was already aborted, or the transaction is not + * found. `Rollback` never returns `ABORTED`. + * + * Sample code: + * ``` + * try { + * $spannerClient = new SpannerClient(); + * $formattedSession = SpannerClient::formatSessionName("[PROJECT]", "[INSTANCE]", "[DATABASE]", "[SESSION]"); + * $transactionId = ""; + * $spannerClient->rollback($formattedSession, $transactionId); + * } finally { + * $spannerClient->close(); + * } + * ``` + * + * @param string $session Required. The session in which the transaction to roll back is running. + * @param string $transactionId Required. The transaction to roll back. + * @param array $optionalArgs { + * Optional. + * + * @type \Google\GAX\RetrySettings $retrySettings + * Retry settings to use for this call. If present, then + * $timeoutMillis is ignored. + * @type int $timeoutMillis + * Timeout to use for this call. Only used if $retrySettings + * is not set. + * } + * + * @throws \Google\GAX\ApiException if the remote call fails + */ + public function rollback($session, $transactionId, $optionalArgs = []) + { + $request = new RollbackRequest(); + $request->setSession($session); + $request->setTransactionId($transactionId); + + $mergedSettings = $this->defaultCallSettings['rollback']->merge( + new CallSettings($optionalArgs) + ); + $callable = ApiCallable::createApiCall( + $this->spannerStub, + 'Rollback', + $mergedSettings, + $this->descriptors['rollback'] + ); + + return $callable( + $request, + [], + ['call_credentials_callback' => $this->createCredentialsCallback()]); + } + + /** + * Initiates an orderly shutdown in which preexisting calls continue but new + * calls are immediately cancelled. + */ + public function close() + { + $this->spannerStub->close(); + } + + private function createCredentialsCallback() + { + return $this->grpcCredentialsHelper->createCallCredentialsCallback(); + } +} diff --git a/src/Spanner/V1/resources/spanner_client_config.json b/src/Spanner/V1/resources/spanner_client_config.json new file mode 100644 index 000000000000..db4ced68c440 --- /dev/null +++ b/src/Spanner/V1/resources/spanner_client_config.json @@ -0,0 +1,78 @@ +{ + "interfaces": { + "google.spanner.v1.Spanner": { + "retry_codes": { + "retry_codes_def": { + "idempotent": [ + "DEADLINE_EXCEEDED", + "UNAVAILABLE" + ], + "non_idempotent": [] + } + }, + "retry_params": { + "default": { + "initial_retry_delay_millis": 1000, + "retry_delay_multiplier": 1.3, + "max_retry_delay_millis": 32000, + "initial_rpc_timeout_millis": 60000, + "rpc_timeout_multiplier": 1.0, + "max_rpc_timeout_millis": 60000, + "total_timeout_millis": 600000 + } + }, + "methods": { + "CreateSession": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + }, + "GetSession": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "DeleteSession": { + "timeout_millis": 30000, + "retry_codes_name": "idempotent", + "retry_params_name": "default" + }, + "ExecuteSql": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + }, + "ExecuteStreamingSql": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + }, + "Read": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + }, + "StreamingRead": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + }, + "BeginTransaction": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + }, + "Commit": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + }, + "Rollback": { + "timeout_millis": 30000, + "retry_codes_name": "non_idempotent", + "retry_params_name": "default" + } + } + } + } +} diff --git a/src/Spanner/ValueInterface.php b/src/Spanner/ValueInterface.php new file mode 100644 index 000000000000..e5e140d1c751 --- /dev/null +++ b/src/Spanner/ValueInterface.php @@ -0,0 +1,44 @@ +returnInt64AsObject = $returnInt64AsObject; + } + + /** + * Accepts an array of key/value pairs, where the key is a SQL parameter + * name and the value is the value interpolated by the server, and returns + * an array of parameters and inferred parameter types. + * + * @param array $parameters The key/value parameters. + * @return array An associative array containing params and paramTypes. + */ + public function formatParamsForExecuteSql(array $parameters) + { + $paramTypes = []; + + foreach ($parameters as $key => $value) { + list ($parameters[$key], $paramTypes[$key]) = $this->paramType($value); + } + + return [ + 'params' => $parameters, + 'paramTypes' => $paramTypes + ]; + } + + /** + * Accepts a list of values and encodes the value into a format accepted by + * the Spanner API. + * + * @param array $values The list of values + * @return array The encoded values + */ + public function encodeValuesAsSimpleType(array $values) + { + $res = []; + foreach ($values as $value) { + $res[] = $this->paramType($value)[0]; + } + + return $res; + } + + /** + * Accepts a list of columns (with name and type) and a row from read or + * executeSql and decodes each value to its corresponding PHP type. + * + * @param array $columns The list of columns + * @param array $row The row data. + * @return array The decoded row data. + */ + public function decodeValues(array $columns, array $row, $extractResult = false) + { + $cols = []; + $types = []; + + foreach ($columns as $index => $column) { + $cols[] = (isset($column['name'])) + ? $column['name'] + : $index; + $types[] = $column['type']; + } + + $res = []; + foreach ($row as $index => $value) { + $i = $cols[$index]; + $res[$i] = $this->decodeValue($value, $types[$index]); + } + + return $res; + } + + /** + * Convert a timestamp string to a Timestamp class with nanosecond support. + * + * @param string $timestamp The timestamp string + * @return Timestamp + */ + public function createTimestampWithNanos($timestamp) + { + $matches = []; + preg_match(self::NANO_REGEX, $timestamp, $matches); + $timestamp = preg_replace(self::NANO_REGEX, '.000000Z', $timestamp); + + $dt = \DateTimeImmutable::createFromFormat(Timestamp::FORMAT, $timestamp); + return new Timestamp($dt, (isset($matches[1])) ? $matches[1] : 0); + } + + /** + * Convert a single value to its corresponding PHP type. + * + * @param mixed $value The value to decode + * @param array $type The value type + * @return mixed + */ + private function decodeValue($value, array $type) + { + switch ($type['code']) { + case self::TYPE_INT64: + $value = $this->returnInt64AsObject + ? new Int64($value) + : (int) $value; + break; + + case self::TYPE_TIMESTAMP: + $value = $this->createTimestampWithNanos($value); + break; + + case self::TYPE_DATE: + $value = new Date(new \DateTimeImmutable($value)); + break; + + case self::TYPE_BYTES: + $value = new Bytes(base64_decode($value)); + break; + + case self::TYPE_ARRAY: + $res = []; + foreach ($value as $item) { + $res[] = $this->decodeValue($item, $type['arrayElementType']); + } + + $value = $res; + break; + + case self::TYPE_STRUCT: + $value = $this->decodeValues($type['structType']['fields'], $value, true); + break; + + case self::TYPE_FLOAT64: + // NaN, Infinite and -Infinite are possible FLOAT64 values, + // but when the gRPC response is decoded, they are represented + // as strings. This conditional checks for a string, converts to + // an equivalent double value, or dies if something really weird + // happens. + if (is_string($value)) { + switch ($value) { + case 'NaN': + $value = NAN; + break; + + case 'Infinity': + $value = INF; + break; + + case '-Infinity': + $value = -INF; + break; + + default: + throw new \RuntimeException(sprintf( + 'Unexpected string value %s encountered in FLOAT64 field.', + $value + )); + } + } + + break; + } + + return $value; + } + + /** + * Create a spanner parameter type value object from a PHP value type. + * + * @param mixed $value The PHP value + * @return array The Value type + */ + private function paramType($value) + { + $phpType = gettype($value); + switch ($phpType) { + case 'boolean': + $type = $this->typeObject(self::TYPE_BOOL); + break; + + case 'integer': + $value = (string) $value; + $type = $this->typeObject(self::TYPE_INT64); + break; + + case 'double': + $type = $this->typeObject(self::TYPE_FLOAT64); + break; + + case 'string': + $type = $this->typeObject(self::TYPE_STRING); + break; + + case 'resource': + $type = $this->typeObject(self::TYPE_BYTES); + $value = base64_encode(stream_get_contents($value)); + break; + + case 'object': + list ($type, $value) = $this->objectParam($value); + break; + + case 'array': + if ($this->isAssoc($value)) { + throw new \InvalidArgumentException( + 'Associative arrays are not supported. Did you mean to call a batch method?' + ); + } + + $res = []; + $types = []; + foreach ($value as $element) { + $type = $this->paramType($element); + $res[] = $type[0]; + $types[] = $type[1]['code']; + } + + if (count(array_unique($types)) !== 1) { + throw new \InvalidArgumentException('Array values may not be of mixed type'); + } + + $type = $this->typeObject( + self::TYPE_ARRAY, + $this->typeObject($types[0]), + 'arrayElementType' + ); + + $value = $res; + break; + + case 'NULL': + $type = null; + break; + + default: + throw new \InvalidArgumentException(sprintf( + 'Unrecognized value type %s. Please ensure you are using the latest version of google/cloud.', + $phpType + )); + break; + } + + return [$value, $type]; + } + + private function objectParam($value) + { + if ($value instanceof ValueInterface) { + return [ + $this->typeObject($value->type()), + $value->formatAsString() + ]; + } + + if ($value instanceof Int64) { + return [ + $this->typeObject(self::TYPE_INT64), + $value->get() + ]; + } + + throw new \InvalidArgumentException(sprintf( + 'Unrecognized value type %s. Please ensure you are using the latest version of google/cloud.', + get_class($value) + )); + } + + private function typeObject($type, array $nestedDefinition = [], $nestedDefinitionType = null) + { + return array_filter([ + 'code' => $type, + $nestedDefinitionType => $nestedDefinition + ]); + } +} diff --git a/src/Spanner/composer.json b/src/Spanner/composer.json new file mode 100644 index 000000000000..c71551886e92 --- /dev/null +++ b/src/Spanner/composer.json @@ -0,0 +1,26 @@ +{ + "name": "google/cloud-spanner", + "description": "Cloud Spanner Client for PHP", + "license": "Apache-2.0", + "minimum-stability": "stable", + "require": { + "google/cloud-core": "*" + }, + "suggest": { + "google/gax": "Required to support gRPC", + "google/proto-client-php": "Required to support gRPC" + }, + "extra": { + "component": { + "id": "cloud-spanner", + "target": "GoogleCloudPlatform/google-cloud-php-spanner.git", + "path": "src/Spanner", + "entry": "SpannerClient.php" + } + }, + "autoload": { + "psr-4": { + "Google\\Cloud\\Spanner\\": "" + } + } +} diff --git a/tests/snippets/BigQuery/BigQueryClientTest.php b/tests/snippets/BigQuery/BigQueryClientTest.php index 58cddc7a89fb..b6527511ef23 100644 --- a/tests/snippets/BigQuery/BigQueryClientTest.php +++ b/tests/snippets/BigQuery/BigQueryClientTest.php @@ -63,8 +63,8 @@ class BigQueryClientTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->client = new \BigQueryClientStub; - $this->client->setConnection($this->connection->reveal()); + $this->client = \Google\Cloud\Dev\stub(BigQueryClient::class); + $this->client->___setProperty('connection', $this->connection->reveal()); } public function testClass() @@ -91,7 +91,7 @@ public function testRunQuery() $this->connection->getQueryResults(Argument::any()) ->shouldBeCalledTimes(1) ->willReturn($this->result); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('queryResults'); $this->assertInstanceOf(QueryResults::class, $res->returnVal()); @@ -134,7 +134,7 @@ public function testRunQueryWithNamedParameters() $this->connection->getQueryResults(Argument::any()) ->shouldBeCalledTimes(1) ->willReturn($this->result); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('queryResults'); $this->assertInstanceOf(QueryResults::class, $res->returnVal()); @@ -167,7 +167,7 @@ public function testRunQueryWithPositionalParameters() $this->connection->getQueryResults(Argument::any()) ->shouldBeCalledTimes(1) ->willReturn($this->result); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('queryResults'); $this->assertInstanceOf(QueryResults::class, $res->returnVal()); @@ -198,7 +198,7 @@ public function testRunQueryAsJob() ], $this->result ); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('queryResults'); $this->assertInstanceOf(QueryResults::class, $res->returnVal()); @@ -209,7 +209,7 @@ public function testJob() { $snippet = $this->snippetFromMethod(BigQueryClient::class, 'job'); $snippet->addLocal('bigQuery', $this->client); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('job'); $this->assertInstanceOf(Job::class, $res->returnVal()); @@ -230,7 +230,7 @@ public function testJobs() ] ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('jobs'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); @@ -241,7 +241,7 @@ public function testDataset() { $snippet = $this->snippetFromMethod(BigQueryClient::class, 'dataset'); $snippet->addLocal('bigQuery', $this->client); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('dataset'); $this->assertInstanceOf(Dataset::class, $res->returnVal()); @@ -262,7 +262,7 @@ public function testDatasets() ] ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('datasets'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); @@ -276,7 +276,7 @@ public function testCreateDataset() $this->connection->insertDataset(Argument::any()) ->shouldBeCalledTimes(1) ->willReturn([]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('dataset'); $this->assertInstanceOf(Dataset::class, $res->returnVal()); @@ -286,7 +286,7 @@ public function testBytes() { $snippet = $this->snippetFromMethod(BigQueryClient::class, 'bytes'); $snippet->addLocal('bigQuery', $this->client); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('bytes'); $this->assertInstanceOf(Bytes::class, $res->returnVal()); @@ -296,7 +296,7 @@ public function testDate() { $snippet = $this->snippetFromMethod(BigQueryClient::class, 'date'); $snippet->addLocal('bigQuery', $this->client); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('date'); $this->assertInstanceOf(Date::class, $res->returnVal()); @@ -306,7 +306,7 @@ public function testInt64() { $snippet = $this->snippetFromMethod(BigQueryClient::class, 'int64'); $snippet->addLocal('bigQuery', $this->client); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('int64'); $this->assertInstanceOf(Int64::class, $res->returnVal()); @@ -316,7 +316,7 @@ public function testTime() { $snippet = $this->snippetFromMethod(BigQueryClient::class, 'time'); $snippet->addLocal('bigQuery', $this->client); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('time'); $this->assertInstanceOf(Time::class, $res->returnVal()); @@ -326,7 +326,7 @@ public function testTimestamp() { $snippet = $this->snippetFromMethod(BigQueryClient::class, 'timestamp'); $snippet->addLocal('bigQuery', $this->client); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('timestamp'); $this->assertInstanceOf(Timestamp::class, $res->returnVal()); diff --git a/tests/snippets/BigQuery/QueryResultsTest.php b/tests/snippets/BigQuery/QueryResultsTest.php index f79b7450a992..058164489466 100644 --- a/tests/snippets/BigQuery/QueryResultsTest.php +++ b/tests/snippets/BigQuery/QueryResultsTest.php @@ -63,14 +63,14 @@ public function setUp() $this->reload = []; $this->connection = $this->prophesize(ConnectionInterface::class); - $this->qr = new \QueryResultsStub( + $this->qr = \Google\Cloud\Dev\stub(QueryResults::class, [ $this->connection->reveal(), self::JOB_ID, self::PROJECT, $this->info, $this->reload, new ValueMapper(false) - ); + ]); } public function testRows() @@ -82,7 +82,7 @@ public function testRows() $this->connection->getQueryResults(Argument::any()) ->willReturn($this->info); - $this->qr->setConnection($this->connection->reveal()); + $this->qr->___setProperty('connection', $this->connection->reveal()); $this->qr->reload(); @@ -99,7 +99,7 @@ public function testIsComplete() $this->connection->getQueryResults(Argument::any()) ->willReturn($this->info); - $this->qr->setConnection($this->connection->reveal()); + $this->qr->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('Query complete!', $res->output()); @@ -129,7 +129,7 @@ public function testReload() ->shouldBeCalled() ->willReturn(['jobComplete' => true] + $this->info); - $this->qr->setConnection($this->connection->reveal()); + $this->qr->___setProperty('connection', $this->connection->reveal()); $snippet = $this->snippetFromMethod(QueryResults::class, 'reload'); $snippet->addLocal('queryResults', $this->qr); diff --git a/tests/snippets/BigQuery/TableTest.php b/tests/snippets/BigQuery/TableTest.php index c694d24de82d..088b1e3cf2c1 100644 --- a/tests/snippets/BigQuery/TableTest.php +++ b/tests/snippets/BigQuery/TableTest.php @@ -27,6 +27,7 @@ use Google\Cloud\Core\Upload\MultipartUploader; use Google\Cloud\Dev\Snippet\SnippetTestCase; use Google\Cloud\Storage\Connection\ConnectionInterface as StorageConnectionInterface; +use Google\Cloud\Storage\StorageClient; use Prophecy\Argument; /** @@ -66,14 +67,14 @@ public function setUp() $this->mapper = new ValueMapper(false); $this->connection = $this->prophesize(ConnectionInterface::class); - $this->table = new \TableStub( + $this->table = \Google\Cloud\Dev\Stub(Table::class, [ $this->connection->reveal(), self::ID, self::DSID, self::PROJECT, $this->mapper, $this->info - ); + ]); } public function testExists() @@ -85,7 +86,7 @@ public function testExists() ->shouldBeCalled() ->willReturn([]); - $this->table->setConnection($this->connection->reveal()); + $this->table->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('Table exists!', $res->output()); @@ -99,7 +100,7 @@ public function testDelete() $this->connection->deleteTable(Argument::any()) ->shouldBeCalled(); - $this->table->setConnection($this->connection->reveal()); + $this->table->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -112,7 +113,7 @@ public function testUpdate() $this->connection->patchTable(Argument::any()) ->shouldBeCalled(); - $this->table->setConnection($this->connection->reveal()); + $this->table->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -128,7 +129,7 @@ public function testRows() 'rows' => $this->info['rows'] ]); - $this->table->setConnection($this->connection->reveal()); + $this->table->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('rows'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); @@ -137,11 +138,11 @@ public function testRows() public function testCopy() { - $bq = new \BigQueryClientStub; + $bq = \Google\Cloud\Dev\stub(BigQueryClient::class); $snippet = $this->snippetFromMethod(Table::class, 'copy'); $snippet->addLocal('bigQuery', $bq); - $bq->setConnection($this->connection->reveal()); + $bq->___setProperty('connection', $this->connection->reveal()); $this->connection->insertJob(Argument::any()) ->shouldBeCalled() @@ -151,7 +152,7 @@ public function testCopy() ] ]); - $this->table->setConnection($this->connection->reveal()); + $this->table->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('job'); $this->assertInstanceOf(Job::class, $res->returnVal()); @@ -159,8 +160,8 @@ public function testCopy() public function testExport() { - $storage = new \StorageClientStub; - $storage->setConnection($this->prophesize(StorageConnectionInterface::class)->reveal()); + $storage = \Google\Cloud\Dev\stub(StorageClient::class); + $storage->___setProperty('connection', $this->prophesize(StorageConnectionInterface::class)->reveal()); $snippet = $this->snippetFromMethod(Table::class, 'export'); $snippet->addLocal('storage', $storage); @@ -174,7 +175,7 @@ public function testExport() ] ]); - $this->table->setConnection($this->connection->reveal()); + $this->table->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('job'); $this->assertInstanceOf(Job::class, $res->returnVal()); @@ -199,7 +200,7 @@ public function testLoad() ->shouldBeCalled() ->willReturn($uploader->reveal()); - $this->table->setConnection($this->connection->reveal()); + $this->table->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('job'); $this->assertInstanceOf(Job::class, $res->returnVal()); @@ -207,8 +208,8 @@ public function testLoad() public function testLoadFromStorage() { - $storage = new \StorageClientStub; - $storage->setConnection($this->prophesize(StorageConnectionInterface::class)->reveal()); + $storage = \Google\Cloud\Dev\stub(StorageClient::class); + $storage->___setProperty('connection', $this->prophesize(StorageConnectionInterface::class)->reveal()); $snippet = $this->snippetFromMethod(Table::class, 'loadFromStorage'); $snippet->addLocal('storage', $storage); @@ -227,7 +228,7 @@ public function testLoadFromStorage() ->shouldBeCalled() ->willReturn($uploader->reveal()); - $this->table->setConnection($this->connection->reveal()); + $this->table->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('job'); $this->assertInstanceOf(Job::class, $res->returnVal()); @@ -244,7 +245,7 @@ public function testInsertRow() ]); - $this->table->setConnection($this->connection->reveal()); + $this->table->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('insertResponse'); $this->assertInstanceOf(InsertResponse::class, $res->returnVal()); @@ -261,7 +262,7 @@ public function testInsertRows() ]); - $this->table->setConnection($this->connection->reveal()); + $this->table->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('insertResponse'); $this->assertInstanceOf(InsertResponse::class, $res->returnVal()); @@ -287,7 +288,7 @@ public function testReload() 'friendlyName' => 'El Jefe' ]); - $this->table->setConnection($this->connection->reveal()); + $this->table->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('El Jefe', $res->output()); diff --git a/tests/snippets/BigQuery/TimeTest.php b/tests/snippets/BigQuery/TimeTest.php index 9f782d703d81..0b3e072da53e 100644 --- a/tests/snippets/BigQuery/TimeTest.php +++ b/tests/snippets/BigQuery/TimeTest.php @@ -17,6 +17,7 @@ namespace Google\Cloud\Tests\Snippets\BigQuery; +use Google\Cloud\BigQuery\BigQueryClient; use Google\Cloud\BigQuery\Time; use Google\Cloud\Dev\Snippet\SnippetTestCase; @@ -28,7 +29,7 @@ class TimeTest extends SnippetTestCase public function testClass() { $snippet = $this->snippetFromClass(Time::class); - $snippet->addLocal('bigQuery', new \BigQueryClientStub); + $snippet->addLocal('bigQuery', \Google\Cloud\Dev\stub(BigQueryClient::class)); $res = $snippet->invoke('time'); $this->assertInstanceOf(Time::class, $res->returnVal()); diff --git a/tests/snippets/BigQuery/TimestampTest.php b/tests/snippets/BigQuery/TimestampTest.php index 9d79bfa88023..d732c8ce3cec 100644 --- a/tests/snippets/BigQuery/TimestampTest.php +++ b/tests/snippets/BigQuery/TimestampTest.php @@ -17,6 +17,7 @@ namespace Google\Cloud\Tests\Snippets\BigQuery; +use Google\Cloud\BigQuery\BigQueryClient; use Google\Cloud\BigQuery\Timestamp; use Google\Cloud\Dev\Snippet\SnippetTestCase; @@ -28,7 +29,7 @@ class TimestampTest extends SnippetTestCase public function testClass() { $snippet = $this->snippetFromClass(Timestamp::class); - $snippet->addLocal('bigQuery', new \BigQueryClientStub); + $snippet->addLocal('bigQuery', \Google\Cloud\Dev\stub(BigQueryClient::class)); $res = $snippet->invoke('timestamp'); $this->assertInstanceOf(Timestamp::class, $res->returnVal()); diff --git a/tests/snippets/Core/Iam/IamTest.php b/tests/snippets/Core/Iam/IamTest.php index efec040851ed..456e92f4d6e6 100644 --- a/tests/snippets/Core/Iam/IamTest.php +++ b/tests/snippets/Core/Iam/IamTest.php @@ -39,8 +39,8 @@ public function setUp() $this->resource = 'testObject'; $this->connection = $this->prophesize(IamConnectionInterface::class); - $this->iam = new \IamStub($this->connection->reveal(), $this->resource); - $this->iam->setConnection($this->connection->reveal()); + $this->iam = \Google\Cloud\Dev\stub(Iam::class, [$this->connection->reveal(), $this->resource]); + $this->iam->___setProperty('connection', $this->connection->reveal()); } public function testClass() @@ -60,7 +60,7 @@ public function testPolicy() ->shouldBeCalled() ->willReturn('foo'); - $this->iam->setConnection($this->connection->reveal()); + $this->iam->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('policy'); @@ -92,7 +92,7 @@ public function testSetPolicy() 'resource' => $this->resource ])->shouldBeCalled()->willReturn('foo'); - $this->iam->setConnection($this->connection->reveal()); + $this->iam->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('policy'); @@ -116,7 +116,7 @@ public function testTestPermissions() ->shouldBeCalled() ->willReturn(['permissions' => $permissions]); - $this->iam->setConnection($this->connection->reveal()); + $this->iam->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('allowedPermissions'); $this->assertEquals($permissions, $res->returnVal()); @@ -131,7 +131,7 @@ public function testReload() ->shouldBeCalled() ->willReturn('foo'); - $this->iam->setConnection($this->connection->reveal()); + $this->iam->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('policy'); $this->assertEquals('foo', $res->returnVal()); diff --git a/tests/snippets/Core/LongRunning/LongRunningOperationTest.php b/tests/snippets/Core/LongRunning/LongRunningOperationTest.php new file mode 100644 index 000000000000..7f02270652b9 --- /dev/null +++ b/tests/snippets/Core/LongRunning/LongRunningOperationTest.php @@ -0,0 +1,301 @@ +connection = $this->prophesize(LongRunningConnectionInterface::class); + $this->callables = [ + ['typeUrl' => self::TYPE, 'callable' => function($res) { return $res; }] + ]; + $this->operation = \Google\Cloud\Dev\stub(LongRunningOperation::class, [ + $this->connection->reveal(), + self::NAME, + $this->callables + ]); + } + + public function testName() + { + $snippet = $this->snippetFromMethod(LongRunningOperation::class, 'name'); + $snippet->addLocal('operation', $this->operation); + + $res = $snippet->invoke('name'); + $this->assertEquals(self::NAME, $res->returnVal()); + } + + public function testDone() + { + $this->connection->get(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'done' => true, + 'response' => [], + 'metadata' => [ + 'typeUrl' => self::TYPE + ] + ]); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snippet = $this->snippetFromMethod(LongRunningOperation::class, 'done'); + $snippet->addLocal('operation', $this->operation); + + $res = $snippet->invoke(); + $this->assertEquals('The operation is done!', $res->output()); + } + + public function testStateInProgress() + { + $this->connection->get(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'done' => false + ]); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snippet = $this->snippetFromMethod(LongRunningOperation::class, 'state'); + $snippet->addLocal('operation', $this->operation); + $snippet->addUse(LongRunningOperation::class); + + $res = $snippet->invoke(); + $this->assertEquals('Operation is in progress', $res->output()); + } + + public function testStateDone() + { + $this->connection->get(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'done' => true, + 'response' => [ + 'foo' => 'bar' + ], + 'metadata' => [ + 'typeUrl' => self::TYPE + ] + ]); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snippet = $this->snippetFromMethod(LongRunningOperation::class, 'state'); + $snippet->addUse(LongRunningOperation::class); + $snippet->addLocal('operation', $this->operation); + + $res = $snippet->invoke(); + $this->assertEquals('Operation succeeded', $res->output()); + } + + public function testStateFailed() + { + $this->connection->get(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'done' => true, + 'response' => [], + 'error' => [], + 'metadata' => [ + 'typeUrl' => self::TYPE + ] + ]); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snippet = $this->snippetFromMethod(LongRunningOperation::class, 'state'); + $snippet->addLocal('operation', $this->operation); + $snippet->addUse(LongRunningOperation::class); + + $res = $snippet->invoke(); + $this->assertEquals('Operation failed', $res->output()); + } + + public function testResult() + { + $result = [ + 'foo' => 'bar' + ]; + $this->connection->get(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'done' => true, + 'response' => $result, + 'metadata' => [ + 'typeUrl' => self::TYPE + ] + ]); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snippet = $this->snippetFromMethod(LongRunningOperation::class, 'result'); + $snippet->addLocal('operation', $this->operation); + + $res = $snippet->invoke('result'); + $this->assertEquals($result, $res->returnVal()); + } + + public function testError() + { + $result = [ + 'foo' => 'bar' + ]; + $this->connection->get(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'done' => true, + 'response' => [], + 'error' => $result, + 'metadata' => [ + 'typeUrl' => self::TYPE + ] + ]); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snippet = $this->snippetFromMethod(LongRunningOperation::class, 'error'); + $snippet->addLocal('operation', $this->operation); + + $res = $snippet->invoke('error'); + $this->assertEquals($result, $res->returnVal()); + } + + public function testInfo() + { + $result = [ + 'done' => true, + 'response' => [ + 'foo' => 'bar' + ], + 'metadata' => [ + 'typeUrl' => self::TYPE + ] + ]; + $this->connection->get(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($result); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snippet = $this->snippetFromMethod(LongRunningOperation::class, 'info'); + $snippet->addLocal('operation', $this->operation); + + $res = $snippet->invoke('info'); + $this->assertEquals($result, $res->returnVal()); + + $snippet->invoke(); + } + + public function testReload() + { + $result = [ + 'done' => true, + 'response' => [ + 'foo' => 'bar' + ], + 'metadata' => [ + 'typeUrl' => self::TYPE + ] + ]; + $this->connection->get(Argument::any()) + ->shouldBeCalledTimes(2) + ->willReturn($result); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snippet = $this->snippetFromMethod(LongRunningOperation::class, 'reload'); + $snippet->addLocal('operation', $this->operation); + + $res = $snippet->invoke('result'); + $this->assertEquals($result, $res->returnVal()); + + $snippet->invoke(); + } + + public function testPollUntilComplete() + { + $result1 = [ + 'done' => false, + ]; + + $result2 = [ + 'done' => true, + 'response' => [ + 'foo' => 'bar' + ], + 'metadata' => [ + 'typeUrl' => self::TYPE + ] + ]; + + $this->connection->get(Argument::any()) + ->shouldBeCalledTimes(2) + ->willReturn($result1, $result2); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snippet = $this->snippetFromMethod(LongRunningOperation::class, 'pollUntilComplete'); + $snippet->addLocal('operation', $this->operation); + + $res = $snippet->invoke('result'); + $this->assertEquals($result2['response'], $res->returnVal()); + } + + public function testCancel() + { + $this->connection->cancel(Argument::any()) + ->shouldBeCalled(); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snippet = $this->snippetFromMethod(LongRunningOperation::class, 'cancel'); + $snippet->addLocal('operation', $this->operation); + + $res = $snippet->invoke(); + } + + public function testDelete() + { + $this->connection->delete(Argument::any()) + ->shouldBeCalled(); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snippet = $this->snippetFromMethod(LongRunningOperation::class, 'delete'); + $snippet->addLocal('operation', $this->operation); + + $res = $snippet->invoke(); + } +} diff --git a/tests/snippets/Datastore/Query/GqlQueryTest.php b/tests/snippets/Datastore/Query/GqlQueryTest.php index 6eac4c542cdd..d8158e42bff9 100644 --- a/tests/snippets/Datastore/Query/GqlQueryTest.php +++ b/tests/snippets/Datastore/Query/GqlQueryTest.php @@ -19,8 +19,9 @@ use Google\Cloud\Datastore\Connection\ConnectionInterface; use Google\Cloud\Datastore\DatastoreClient; -use Google\Cloud\Datastore\EntityMapper; use Google\Cloud\Datastore\EntityIterator; +use Google\Cloud\Datastore\EntityMapper; +use Google\Cloud\Datastore\Operation; use Google\Cloud\Datastore\Query\GqlQuery; use Google\Cloud\Dev\Snippet\SnippetTestCase; use Prophecy\Argument; @@ -38,12 +39,12 @@ public function setUp() { $this->datastore = new DatastoreClient; $this->connection = $this->prophesize(ConnectionInterface::class); - $this->operation = new \OperationStub( + $this->operation = \Google\Cloud\Dev\Stub(Operation::class, [ $this->connection->reveal(), 'my-awesome-project', '', new EntityMapper('my-awesome-project', true, false) - ); + ]); } public function testClass() @@ -69,7 +70,7 @@ public function testClass() ] ]); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $snippet = $this->snippetFromClass(GqlQuery::class); $snippet->addLocal('operation', $this->operation); diff --git a/tests/snippets/Datastore/Query/QueryTest.php b/tests/snippets/Datastore/Query/QueryTest.php index da561cccf0e8..d7074c6e5079 100644 --- a/tests/snippets/Datastore/Query/QueryTest.php +++ b/tests/snippets/Datastore/Query/QueryTest.php @@ -22,6 +22,7 @@ use Google\Cloud\Datastore\EntityIterator; use Google\Cloud\Datastore\EntityMapper; use Google\Cloud\Datastore\Key; +use Google\Cloud\Datastore\Operation; use Google\Cloud\Datastore\Query\Query; use Google\Cloud\Dev\Snippet\SnippetTestCase; use Prophecy\Argument; @@ -42,12 +43,12 @@ public function setUp() $this->datastore = new DatastoreClient; $this->connection = $this->prophesize(ConnectionInterface::class); - $this->operation = new \OperationStub( + $this->operation = \Google\Cloud\Dev\stub(Operation::class, [ $this->connection->reveal(), 'my-awesome-project', '', $mapper - ); + ]); $this->query = new Query($mapper); } @@ -76,7 +77,7 @@ public function testClass() ] ]); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $snippet = $this->snippetFromClass(Query::class); $snippet->addLocal('operation', $this->operation); diff --git a/tests/snippets/Datastore/TransactionTest.php b/tests/snippets/Datastore/TransactionTest.php index f15784a8fd6c..a6f701677d79 100644 --- a/tests/snippets/Datastore/TransactionTest.php +++ b/tests/snippets/Datastore/TransactionTest.php @@ -44,12 +44,12 @@ class TransactionTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->operation = new \OperationStub( + $this->operation = \Google\Cloud\Dev\stub(Operation::class, [ $this->connection->reveal(), self::PROJECT, '', new EntityMapper(self::PROJECT, false, false) - ); + ]); $this->transaction = new Transaction($this->operation, self::PROJECT, $this->transactionId); $this->datastore = new DatastoreClient; $this->key = new Key('my-awesome-project', [ @@ -101,7 +101,7 @@ public function testInsert() ] ]); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -126,7 +126,7 @@ public function testInsertBatch() $this->allocateIdsConnectionMock(); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -152,7 +152,7 @@ public function testUpdate() ] ]); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -198,7 +198,7 @@ public function testUpsert() ] ]); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -241,7 +241,7 @@ public function testDelete() ] ]); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -292,7 +292,7 @@ public function testLookup() ] ]); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('Bob', $res->output()); @@ -342,7 +342,7 @@ public function testLookupBatch() ] ]); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals("Bob", explode("\n", $res->output())[0]); @@ -382,7 +382,7 @@ public function testRunQuery() ] ]); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('result'); $this->assertEquals('Bob', $res->output()); @@ -396,7 +396,7 @@ public function testCommit() $this->connection->commit(Argument::any()) ->shouldBeCalled(); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -409,7 +409,7 @@ public function testRollback() $this->connection->rollback(Argument::any()) ->shouldBeCalled(); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } diff --git a/tests/snippets/Language/LanguageClientTest.php b/tests/snippets/Language/LanguageClientTest.php index d23d1811f7a6..f2462709c9b2 100644 --- a/tests/snippets/Language/LanguageClientTest.php +++ b/tests/snippets/Language/LanguageClientTest.php @@ -33,8 +33,8 @@ class LanguageClientTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->client = new \LanguageClientStub; - $this->client->setConnection($this->connection->reveal()); + $this->client = \Google\Cloud\Dev\stub(NaturalLanguageClient::class); + $this->client->___setProperty('connection', $this->connection->reveal()); } public function testClass() @@ -56,7 +56,7 @@ public function testAnalyzeEntities() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $snippet = $this->snippetFromMethod(LanguageClient::class, 'analyzeEntities'); $snippet->addLocal('language', $this->client); @@ -75,7 +75,7 @@ public function testAnalyzeSentiment() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $snippet = $this->snippetFromMethod(LanguageClient::class, 'analyzeSentiment'); $snippet->addLocal('language', $this->client); @@ -98,7 +98,7 @@ public function testAnalyzeSyntax() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $snippet = $this->snippetFromMethod(LanguageClient::class, 'analyzeSyntax'); $snippet->addLocal('language', $this->client); @@ -117,7 +117,7 @@ public function testAnnotateTextAllFeatures() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $snippet = $this->snippetFromMethod(LanguageClient::class, 'annotateText'); $snippet->addLocal('language', $this->client); @@ -139,7 +139,7 @@ public function testAnnotateTextSomeFeatures() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $snippet = $this->snippetFromMethod(LanguageClient::class, 'annotateText', 1); $snippet->addLocal('language', $this->client); diff --git a/tests/snippets/Logging/LoggerTest.php b/tests/snippets/Logging/LoggerTest.php index 3fd1f0da55a8..5c74fcd2d3a9 100644 --- a/tests/snippets/Logging/LoggerTest.php +++ b/tests/snippets/Logging/LoggerTest.php @@ -38,11 +38,11 @@ class LoggerTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->logger = new \LoggerStub( + $this->logger = \Google\Cloud\Dev\stub(Logger::class, [ $this->connection->reveal(), self::NAME, self::PROJECT - ); + ]); } public function testClass() @@ -61,7 +61,7 @@ public function testDelete() $this->connection->deleteLog(Argument::any()) ->shouldBeCalled(); - $this->logger->setConnection($this->connection->reveal()); + $this->logger->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -80,7 +80,7 @@ public function testEntries() ] ]); - $this->logger->setConnection($this->connection->reveal()); + $this->logger->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('entries'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); @@ -106,7 +106,7 @@ public function testEntriesWithFilter() ] ]); - $this->logger->setConnection($this->connection->reveal()); + $this->logger->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('entries'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); @@ -150,7 +150,7 @@ public function testWrite() $this->connection->writeEntries(Argument::any()) ->shouldBeCalled(); - $this->logger->setConnection($this->connection->reveal()); + $this->logger->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -164,7 +164,7 @@ public function testWriteKeyValLevel() $this->connection->writeEntries(Argument::any()) ->shouldBeCalled(); - $this->logger->setConnection($this->connection->reveal()); + $this->logger->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -177,7 +177,7 @@ public function testWriteFactory() $this->connection->writeEntries(Argument::any()) ->shouldBeCalled(); - $this->logger->setConnection($this->connection->reveal()); + $this->logger->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -190,7 +190,7 @@ public function testWriteBatch() $this->connection->writeEntries(Argument::any()) ->shouldBeCalled(); - $this->logger->setConnection($this->connection->reveal()); + $this->logger->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } diff --git a/tests/snippets/Logging/LoggingClientTest.php b/tests/snippets/Logging/LoggingClientTest.php index e794bb8627d5..48cc81a34a7b 100644 --- a/tests/snippets/Logging/LoggingClientTest.php +++ b/tests/snippets/Logging/LoggingClientTest.php @@ -38,8 +38,8 @@ class LoggingClientTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->client = new \LoggingClientStub; - $this->client->setConnection($this->connection->reveal()); + $this->client = \Google\Cloud\Dev\stub(LoggingClient::class); + $this->client->___setProperty('connection', $this->connection->reveal()); } public function testClass() @@ -86,7 +86,7 @@ public function testSinks() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('sinks'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); @@ -103,7 +103,7 @@ public function testCreateMetric() ->shouldBeCalled() ->willReturn([]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('metric'); $this->assertInstanceOf(Metric::class, $res->returnVal()); @@ -132,7 +132,7 @@ public function testMetrics() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('metrics'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); @@ -154,7 +154,7 @@ public function testEntries() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('entries'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); @@ -179,7 +179,7 @@ public function testEntriesWithFilter() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('entries'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); diff --git a/tests/snippets/Logging/MetricTest.php b/tests/snippets/Logging/MetricTest.php index 747dadaffb38..240cf7c890d2 100644 --- a/tests/snippets/Logging/MetricTest.php +++ b/tests/snippets/Logging/MetricTest.php @@ -36,11 +36,11 @@ class MetricTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->metric = new \MetricStub( + $this->metric = \Google\Cloud\Dev\stub(Metric::class, [ $this->connection->reveal(), self::METRIC, self::PROJECT - ); + ]); } public function testClass() @@ -60,7 +60,7 @@ public function testExists() ->shouldBeCalled() ->willReturn([]); - $this->metric->setConnection($this->connection->reveal()); + $this->metric->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals("Metric exists!", $res->output()); @@ -74,7 +74,7 @@ public function testDelete() $this->connection->deleteMetric(Argument::any()) ->shouldBeCalled(); - $this->metric->setConnection($this->connection->reveal()); + $this->metric->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -92,7 +92,7 @@ public function testUpdate() ->shouldBeCalled() ->willReturn(['description' => 'Foo']); - $this->metric->setConnection($this->connection->reveal()); + $this->metric->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -106,7 +106,7 @@ public function testInfo() ->shouldBeCalled() ->willReturn(['description' => 'Foo']); - $this->metric->setConnection($this->connection->reveal()); + $this->metric->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('Foo', $res->output()); @@ -121,7 +121,7 @@ public function testReload() ->shouldBeCalled() ->willReturn(['description' => 'Foo']); - $this->metric->setConnection($this->connection->reveal()); + $this->metric->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('Foo', $res->output()); diff --git a/tests/snippets/Logging/SinkTest.php b/tests/snippets/Logging/SinkTest.php index 1a8f9c2cc345..91cd4c77392a 100644 --- a/tests/snippets/Logging/SinkTest.php +++ b/tests/snippets/Logging/SinkTest.php @@ -36,7 +36,11 @@ class SinkTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->sink = new \SinkStub($this->connection->reveal(), self::SINK, self::PROJECT); + $this->sink = \Google\Cloud\Dev\stub(Sink::class, [ + $this->connection->reveal(), + self::SINK, + self::PROJECT + ]); } public function testClass() @@ -56,7 +60,7 @@ public function testExists() ->shouldBeCalled() ->willReturn([]); - $this->sink->setConnection($this->connection->reveal()); + $this->sink->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('Sink exists!', $res->output()); @@ -70,7 +74,7 @@ public function testDelete() $this->connection->deleteSink(Argument::any()) ->shouldBeCalled(); - $this->sink->setConnection($this->connection->reveal()); + $this->sink->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -89,7 +93,7 @@ public function testUpdate() 'destination' => 'Foo' ]); - $this->sink->setConnection($this->connection->reveal()); + $this->sink->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -105,7 +109,7 @@ public function testInfo() 'destination' => 'Foo' ]); - $this->sink->setConnection($this->connection->reveal()); + $this->sink->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('Foo', $res->output()); @@ -122,7 +126,7 @@ public function testReload() 'destination' => 'Foo' ]); - $this->sink->setConnection($this->connection->reveal()); + $this->sink->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('Foo', $res->output()); diff --git a/tests/snippets/PubSub/PubSubClientTest.php b/tests/snippets/PubSub/PubSubClientTest.php index 87c9cc53eb44..4f1e2ab4b904 100644 --- a/tests/snippets/PubSub/PubSubClientTest.php +++ b/tests/snippets/PubSub/PubSubClientTest.php @@ -45,7 +45,7 @@ class PubSubClientTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->client = new \PubSubClientStub(['transport' => 'rest']); + $this->client = \Google\Cloud\Dev\stub(PubSubClient::class, [['transport' => 'rest']]); } public function testClassExample() @@ -73,7 +73,7 @@ public function testCreateTopic() 'name' => self::TOPIC ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $snippet = $this->snippetFromMethod(PubSubClient::class, 'createTopic'); $snippet->addLocal('pubsub', $this->client); @@ -96,7 +96,7 @@ public function testTopic() 'name' => self::TOPIC ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('topic'); @@ -118,7 +118,7 @@ public function testTopics() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('topics'); @@ -138,7 +138,7 @@ public function testSubscribe() 'topic' => self::TOPIC ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('subscription'); @@ -160,7 +160,7 @@ public function testSubscription() 'topic' => self::TOPIC ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('subscription'); @@ -183,7 +183,7 @@ public function testSubscriptions() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('subscriptions'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); diff --git a/tests/snippets/PubSub/SubscriptionTest.php b/tests/snippets/PubSub/SubscriptionTest.php index cbc0e95e220b..ddfce689118e 100644 --- a/tests/snippets/PubSub/SubscriptionTest.php +++ b/tests/snippets/PubSub/SubscriptionTest.php @@ -41,16 +41,16 @@ class SubscriptionTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->subscription = new \SubscriptionStub( + $this->subscription = \Google\Cloud\Dev\stub(Subscription::class, [ $this->connection->reveal(), 'foo', self::SUBSCRIPTION, self::TOPIC, false - ); + ]); - $this->pubsub = new \PubSubClientStub(['transport' => 'rest']); - $this->pubsub->setConnection($this->connection->reveal()); + $this->pubsub = \Google\Cloud\Dev\stub(PubSubClient::class, [['transport' => 'rest']]); + $this->pubsub->___setProperty('connection', $this->connection->reveal()); } public function testClassThroughTopic() @@ -89,7 +89,7 @@ public function testCreate() ->shouldBeCalled() ->willReturn($return); - $this->pubsub->setConnection($this->connection->reveal()); + $this->pubsub->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('result'); $this->assertEquals($return, $res->returnVal()); @@ -116,7 +116,7 @@ public function testDelete() $this->connection->deleteSubscription(Argument::any()) ->shouldBeCalled(); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -129,7 +129,7 @@ public function testExists() $this->connection->getSubscription(Argument::any()) ->shouldBeCalled(); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('Subscription exists!', $res->output()); @@ -144,7 +144,7 @@ public function testInfo() ->shouldBeCalled() ->willReturn(['name' => self::SUBSCRIPTION]); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals(self::SUBSCRIPTION, $res->output()); @@ -159,7 +159,7 @@ public function testReload() ->shouldBeCalled() ->willReturn(['name' => self::SUBSCRIPTION]); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); @@ -180,7 +180,7 @@ public function testPull() ] ]); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('messages'); $this->assertContainsOnlyInstancesOf(Message::class, $res->returnVal()); @@ -203,7 +203,7 @@ public function testAcknowledge() $this->connection->acknowledge(Argument::any()) ->shouldBeCalled(); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -224,7 +224,7 @@ public function testAcknowledgeBatch() $this->connection->acknowledge(Argument::any()) ->shouldBeCalled(); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -248,7 +248,7 @@ public function testModifyAckDeadline() $this->connection->modifyAckDeadline(Argument::any()) ->shouldBeCalled(); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -272,7 +272,7 @@ public function testModifyAckDeadlineBatch() $this->connection->modifyAckDeadline(Argument::any()) ->shouldBeCalled(); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -285,7 +285,7 @@ public function testModifyPushConfig() $this->connection->modifyPushConfig(Argument::any()) ->shouldBeCalled(); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } diff --git a/tests/snippets/PubSub/TopicTest.php b/tests/snippets/PubSub/TopicTest.php index bafb98fa7f34..64e3d3595d7b 100644 --- a/tests/snippets/PubSub/TopicTest.php +++ b/tests/snippets/PubSub/TopicTest.php @@ -17,9 +17,10 @@ namespace Google\Cloud\Tests\Snippets\PubSub; -use Google\Cloud\Dev\Snippet\SnippetTestCase; use Google\Cloud\Core\Iam\Iam; +use Google\Cloud\Dev\Snippet\SnippetTestCase; use Google\Cloud\PubSub\Connection\ConnectionInterface; +use Google\Cloud\PubSub\PubSubClient; use Google\Cloud\PubSub\Subscription; use Google\Cloud\PubSub\Topic; use Google\Cloud\Core\Iterator\ItemIterator; @@ -34,23 +35,25 @@ class TopicTest extends SnippetTestCase const SUBSCRIPTION = 'projects/my-awesome-project/subscriptions/my-new-subscription'; private $connection; + private $pubsub; private $topic; public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->topic = new \TopicStub( + $this->pubsub = \Google\Cloud\Dev\stub(PubSubClient::class); + $this->topic = \Google\Cloud\Dev\stub(Topic::class, [ $this->connection->reveal(), 'my-awesome-project', self::TOPIC, false - ); + ]); } public function testClass() { $snippet = $this->snippetFromClass(Topic::class); - $snippet->addLocal('pubsub', new \PubSubClientStub(['transport' => 'rest'])); + $snippet->addLocal('pubsub', $this->pubsub); $res = $snippet->invoke('topic'); $this->assertInstanceOf(Topic::class, $res->returnVal()); @@ -60,7 +63,7 @@ public function testClass() public function testClassWithFullyQualifiedName() { $snippet = $this->snippetFromClass(Topic::class, 1); - $snippet->addLocal('pubsub', new \PubSubClientStub(['transport' => 'rest'])); + $snippet->addLocal('pubsub', $this->pubsub); $res = $snippet->invoke('topic'); $this->assertInstanceOf(Topic::class, $res->returnVal()); @@ -85,7 +88,7 @@ public function testCreate() ->shouldBeCalled() ->willReturn([]); - $this->topic->setConnection($this->connection->reveal()); + $this->topic->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('topicInfo'); $this->assertEquals([], $res->returnVal()); @@ -99,7 +102,7 @@ public function testDelete() $this->connection->deleteTopic(Argument::any()) ->shouldBeCalled(); - $this->topic->setConnection($this->connection->reveal()); + $this->topic->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -113,7 +116,7 @@ public function testExists() ->shouldBeCalled() ->willReturn([]); - $this->topic->setConnection($this->connection->reveal()); + $this->topic->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('Topic exists', $res->output()); @@ -130,7 +133,7 @@ public function testInfo() 'name' => self::TOPIC ]); - $this->topic->setConnection($this->connection->reveal()); + $this->topic->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals(self::TOPIC, $res->output()); @@ -147,7 +150,7 @@ public function testReload() 'name' => self::TOPIC ]); - $this->topic->setConnection($this->connection->reveal()); + $this->topic->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals(self::TOPIC, $res->output()); @@ -161,7 +164,7 @@ public function testPublish() $this->connection->publishMessage(Argument::any()) ->shouldBeCalled(); - $this->topic->setConnection($this->connection->reveal()); + $this->topic->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -174,7 +177,7 @@ public function testPublishBatch() $this->connection->publishMessage(Argument::any()) ->shouldBeCalled(); - $this->topic->setConnection($this->connection->reveal()); + $this->topic->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -190,7 +193,7 @@ public function testSubscribe() 'name' => self::SUBSCRIPTION ]); - $this->topic->setConnection($this->connection->reveal()); + $this->topic->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('subscription'); $this->assertInstanceOf(Subscription::class, $res->returnVal()); @@ -218,7 +221,7 @@ public function testSubscriptions() ] ]); - $this->topic->setConnection($this->connection->reveal()); + $this->topic->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('subscriptions'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); diff --git a/tests/snippets/ServiceBuilderTest.php b/tests/snippets/ServiceBuilderTest.php index 3beee4f1c67d..85e352bf0e18 100644 --- a/tests/snippets/ServiceBuilderTest.php +++ b/tests/snippets/ServiceBuilderTest.php @@ -24,6 +24,7 @@ use Google\Cloud\Language\LanguageClient; use Google\Cloud\PubSub\PubSubClient; use Google\Cloud\ServiceBuilder; +use Google\Cloud\Spanner\SpannerClient; use Google\Cloud\Speech\SpeechClient; use Google\Cloud\Storage\StorageClient; use Google\Cloud\Trace\TraceClient; @@ -56,6 +57,7 @@ public function serviceBuilderMethods() ['logging', LoggingClient::class, 'logging'], ['language', LanguageClient::class, 'language'], ['pubsub', PubSubClient::class, 'pubsub'], + ['spanner', SpannerClient::class, 'spanner'], ['speech', SpeechClient::class, 'speech'], ['storage', StorageClient::class, 'storage'], ['trace', TraceClient::class, 'trace'], diff --git a/tests/snippets/Spanner/BytesTest.php b/tests/snippets/Spanner/BytesTest.php new file mode 100644 index 000000000000..5b1a629247a6 --- /dev/null +++ b/tests/snippets/Spanner/BytesTest.php @@ -0,0 +1,81 @@ +bytes = new Bytes(self::BYTES); + } + + public function testClass() + { + $snippet = $this->snippetFromClass(Bytes::class); + $res = $snippet->invoke('bytes'); + $this->assertInstanceOf(Bytes::class, $res->returnVal()); + } + + public function testClassCast() + { + $snippet = $this->snippetFromClass(Bytes::class, 1); + $snippet->addLocal('bytes', $this->bytes); + $res = $snippet->invoke(); + + $this->assertEquals(base64_encode(self::BYTES), $res->output()); + } + + public function testGet() + { + $snippet = $this->snippetFromMethod(Bytes::class, 'get'); + $snippet->addLocal('bytes', $this->bytes); + $res = $snippet->invoke('stream'); + + $this->assertInstanceOf(StreamInterface::class, $res->returnVal()); + $this->assertEquals(self::BYTES, (string)$res->returnVal()); + } + + public function testType() + { + $snippet = $this->snippetFromMethod(Bytes::class, 'type'); + $snippet->addLocal('bytes', $this->bytes); + $res = $snippet->invoke(); + $this->assertEquals(ValueMapper::TYPE_BYTES, $res->output()); + } + + public function testFormatAsString() + { + $snippet = $this->snippetFromMethod(Bytes::class, 'formatAsString'); + $snippet->addLocal('bytes', $this->bytes); + $res = $snippet->invoke(); + + $this->assertEquals(base64_encode(self::BYTES), $res->output()); + } +} diff --git a/tests/snippets/Spanner/ConfigurationTest.php b/tests/snippets/Spanner/ConfigurationTest.php new file mode 100644 index 000000000000..181b5fecebdf --- /dev/null +++ b/tests/snippets/Spanner/ConfigurationTest.php @@ -0,0 +1,122 @@ +connection = $this->prophesize(ConnectionInterface::class); + $this->config = \Google\Cloud\Dev\stub(Configuration::class, [ + $this->connection->reveal(), + self::PROJECT, + self::CONFIG + ]); + } + + public function testClass() + { + $snippet = $this->snippetFromClass(Configuration::class); + $res = $snippet->invoke('configuration'); + + $this->assertInstanceOf(Configuration::class, $res->returnVal()); + $this->assertEquals(self::CONFIG, $res->returnVal()->name()); + } + + public function testName() + { + $snippet = $this->snippetFromMethod(Configuration::class, 'name'); + $snippet->addLocal('configuration', $this->config); + + $res = $snippet->invoke('name'); + $this->assertEquals(self::CONFIG, $res->returnVal()); + } + + public function testInfo() + { + $snippet = $this->snippetFromMethod(Configuration::class, 'info'); + $snippet->addLocal('configuration', $this->config); + + $info = [ + 'name' => 'projects/'. self::PROJECT .'/instanceConfigs/'. self::CONFIG, + 'displayName' => self::CONFIG + ]; + + $this->connection->getConfig(Argument::any()) + ->shouldBeCalled() + ->willReturn($info); + + $this->config->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('info'); + $this->assertEquals($info, $res->returnVal()); + } + + public function testExists() + { + $snippet = $this->snippetFromMethod(Configuration::class, 'exists'); + $snippet->addLocal('configuration', $this->config); + + $this->connection->getConfig(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'name' => 'projects/'. self::PROJECT .'/instanceConfigs/'. self::CONFIG, + 'displayName' => self::CONFIG + ]); + + $this->config->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke(); + $this->assertEquals('Configuration exists!', $res->output()); + } + + public function testReload() + { + $info = [ + 'name' => 'projects/'. self::PROJECT .'/instanceConfigs/'. self::CONFIG, + 'displayName' => self::CONFIG + ]; + + $snippet = $this->snippetFromMethod(Configuration::class, 'reload'); + $snippet->addLocal('configuration', $this->config); + + $this->connection->getConfig(Argument::any()) + ->shouldBeCalled() + ->willReturn($info); + + $this->config->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('info'); + $this->assertEquals($info, $res->returnVal()); + } +} diff --git a/tests/snippets/Spanner/DatabaseTest.php b/tests/snippets/Spanner/DatabaseTest.php new file mode 100644 index 000000000000..e7eb9b50b20b --- /dev/null +++ b/tests/snippets/Spanner/DatabaseTest.php @@ -0,0 +1,713 @@ +prophesize(Instance::class); + $instance->name()->willReturn(self::INSTANCE); + + $session = $this->prophesize(Session::class); + + $sessionPool = $this->prophesize(SessionPoolInterface::class); + $sessionPool->session(Argument::any(), Argument::any(), Argument::any()) + ->willReturn($session->reveal()); + + $this->connection = $this->prophesize(ConnectionInterface::class); + $this->database = \Google\Cloud\Dev\stub(Database::class, [ + $this->connection->reveal(), + $instance->reveal(), + $sessionPool->reveal(), + $this->prophesize(LongRunningConnectionInterface::class)->reveal(), + [], + self::PROJECT, + self::DATABASE + ], ['connection', 'operation']); + } + + private function stubOperation() + { + $operation = \Google\Cloud\Dev\stub(Operation::class, [ + $this->connection->reveal(), false + ]); + + $this->database->___setProperty('operation', $operation); + } + + public function testClass() + { + $snippet = $this->snippetFromClass(Database::class); + $res = $snippet->invoke('database'); + $this->assertInstanceOf(Database::class, $res->returnVal()); + $this->assertEquals(self::DATABASE, $res->returnVal()->name()); + } + + public function testClassViaInstance() + { + $snippet = $this->snippetFromClass(Database::class, 1); + $res = $snippet->invoke('database'); + $this->assertInstanceOf(Database::class, $res->returnVal()); + $this->assertEquals(self::DATABASE, $res->returnVal()->name()); + } + + public function testName() + { + $snippet = $this->snippetFromMethod(Database::class, 'name'); + $snippet->addLocal('database', $this->database); + $res = $snippet->invoke('name'); + $this->assertEquals(self::DATABASE, $res->returnVal()); + } + + /** + * @group spanneradmin + */ + public function testExists() + { + $snippet = $this->snippetFromMethod(Database::class, 'exists'); + $snippet->addLocal('database', $this->database); + + $this->connection->getDatabaseDDL(Argument::any()) + ->shouldBeCalled() + ->willReturn(['statements' => []]); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke(); + $this->assertEquals('Database exists!', $res->output()); + } + + /** + * @group spanneradmin + */ + public function testUpdateDdl() + { + $snippet = $this->snippetFromMethod(Database::class, 'updateDdl'); + $snippet->addLocal('database', $this->database); + + $this->connection->updateDatabase(Argument::any()) + ->shouldBeCalled(); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $snippet->invoke(); + } + + /** + * @group spanneradmin + */ + public function testUpdateDdlBatch() + { + $snippet = $this->snippetFromMethod(Database::class, 'updateDdlBatch'); + $snippet->addLocal('database', $this->database); + + $this->connection->updateDatabase(Argument::any()) + ->shouldBeCalled(); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $snippet->invoke(); + } + + /** + * @group spanneradmin + */ + public function testDrop() + { + $snippet = $this->snippetFromMethod(Database::class, 'drop'); + $snippet->addLocal('database', $this->database); + + $this->connection->dropDatabase(Argument::any()) + ->shouldBeCalled(); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $snippet->invoke(); + } + + /** + * @group spanneradmin + */ + public function testDdl() + { + $snippet = $this->snippetFromMethod(Database::class, 'ddl'); + $snippet->addLocal('database', $this->database); + + $stmts = [ + 'CREATE TABLE TestSuites', + 'CREATE TABLE TestCases' + ]; + + $this->connection->getDatabaseDDL(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'statements' => $stmts + ]); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('statements'); + $this->assertEquals($stmts, $res->returnVal()); + } + + public function testSnapshot() + { + $this->connection->beginTransaction(Argument::any(), Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'id' => self::TRANSACTION + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'snapshot'); + $snippet->addLocal('database', $this->database); + + $res = $snippet->invoke('snapshot'); + $this->assertInstanceOf(Snapshot::class, $res->returnVal()); + } + + public function testSnapshotReadTimestamp() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'id' => self::TRANSACTION, + 'readTimestamp' => (new Timestamp(new \DateTime))->formatAsString() + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'snapshot', 1); + $snippet->addLocal('database', $this->database); + + $res = $snippet->invoke('timestamp'); + $this->assertInstanceOf(Timestamp::class, $res->returnVal()); + } + + public function testRunTransaction() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'id' => self::TRANSACTION + ]); + + $this->connection->commit(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'commitTimestamp' => (new Timestamp(new \DateTime))->formatAsString() + ]); + + $this->connection->rollback(Argument::any()) + ->shouldNotBeCalled(); + + $this->connection->executeSql(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'loginCount', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'rows' => [ + [ + 0 + ] + ] + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'runTransaction'); + $snippet->addUse(Transaction::class); + $snippet->addLocal('database', $this->database); + $snippet->addLocal('username', 'foo'); + $snippet->addLocal('password', 'bar'); + + $snippet->invoke(); + } + + public function testRunTransactionRollback() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'id' => self::TRANSACTION + ]); + + $this->connection->commit(Argument::any()) + ->shouldNotBeCalled(); + + $this->connection->rollback(Argument::any()) + ->shouldBeCalled(); + + $this->connection->executeSql(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'metadata' => [ + 'rowType' => [] + ], + 'rows' => [] + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'runTransaction'); + $snippet->addUse(Transaction::class); + $snippet->addLocal('database', $this->database); + $snippet->addLocal('username', 'foo'); + $snippet->addLocal('password', 'bar'); + + $snippet->invoke(); + } + + public function testTransaction() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'id' => self::TRANSACTION + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'transaction'); + $snippet->addLocal('database', $this->database); + $res = $snippet->invoke('transaction'); + $this->assertInstanceOf(Transaction::class, $res->returnVal()); + } + + public function testInsert() + { + $this->connection->commit(Argument::that(function ($args) { + if (!isset($args['mutations'][0]['insert'])) return false; + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => (new Timestamp(new \DateTime))->formatAsString() + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'insert'); + $snippet->addLocal('database', $this->database); + $res = $snippet->invoke(); + } + + + public function testInsertBatch() + { + $this->connection->commit(Argument::that(function ($args) { + if (!isset($args['mutations'][0]['insert'])) return false; + if (!isset($args['mutations'][1]['insert'])) return false; + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => (new Timestamp(new \DateTime))->formatAsString() + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'insertBatch'); + $snippet->addLocal('database', $this->database); + $res = $snippet->invoke(); + } + + public function testUpdate() + { + $this->connection->commit(Argument::that(function ($args) { + if (!isset($args['mutations'][0]['update'])) return false; + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => (new Timestamp(new \DateTime))->formatAsString() + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'update'); + $snippet->addLocal('database', $this->database); + $res = $snippet->invoke(); + } + + + public function testUpdateBatch() + { + $this->connection->commit(Argument::that(function ($args) { + if (!isset($args['mutations'][0]['update'])) return false; + if (!isset($args['mutations'][1]['update'])) return false; + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => (new Timestamp(new \DateTime))->formatAsString() + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'updateBatch'); + $snippet->addLocal('database', $this->database); + $res = $snippet->invoke(); + } + + public function testInsertOrUpdate() + { + $this->connection->commit(Argument::that(function ($args) { + if (!isset($args['mutations'][0]['insertOrUpdate'])) return false; + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => (new Timestamp(new \DateTime))->formatAsString() + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'insertOrUpdate'); + $snippet->addLocal('database', $this->database); + $res = $snippet->invoke(); + } + + + public function testInsertOrUpdateBatch() + { + $this->connection->commit(Argument::that(function ($args) { + if (!isset($args['mutations'][0]['insertOrUpdate'])) return false; + if (!isset($args['mutations'][1]['insertOrUpdate'])) return false; + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => (new Timestamp(new \DateTime))->formatAsString() + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'insertOrUpdateBatch'); + $snippet->addLocal('database', $this->database); + $res = $snippet->invoke(); + } + + public function testReplace() + { + $this->connection->commit(Argument::that(function ($args) { + if (!isset($args['mutations'][0]['replace'])) return false; + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => (new Timestamp(new \DateTime))->formatAsString() + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'replace'); + $snippet->addLocal('database', $this->database); + $res = $snippet->invoke(); + } + + + public function testReplaceBatch() + { + $this->connection->commit(Argument::that(function ($args) { + if (!isset($args['mutations'][0]['replace'])) return false; + if (!isset($args['mutations'][1]['replace'])) return false; + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => (new Timestamp(new \DateTime))->formatAsString() + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'replaceBatch'); + $snippet->addLocal('database', $this->database); + $res = $snippet->invoke(); + } + + public function testDelete() + { + $this->connection->commit(Argument::that(function ($args) { + if (!isset($args['mutations'][0]['delete'])) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => (new Timestamp(new \DateTime))->formatAsString() + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'delete'); + $snippet->addUse(KeySet::class); + $snippet->addLocal('database', $this->database); + $snippet->invoke(); + } + + public function testExecute() + { + $this->connection->executeSql(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'loginCount', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'rows' => [ + [ + 0 + ] + ] + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'execute'); + $snippet->addLocal('database', $this->database); + + $res = $snippet->invoke('result'); + $this->assertInstanceOf(Result::class, $res->returnVal()); + } + + public function testExecuteBeginSnapshot() + { + $this->connection->executeSql(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'loginCount', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ], + 'transaction' => [ + 'id' => self::TRANSACTION + ] + ], + 'rows' => [ + [ + 0 + ] + ] + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'execute', 1); + $snippet->addLocal('database', $this->database); + + $res = $snippet->invoke('result'); + $this->assertInstanceOf(Result::class, $res->returnVal()); + $this->assertInstanceOf(Snapshot::class, $res->returnVal()->snapshot()); + } + + public function testExecuteBeginTransaction() + { + $this->connection->executeSql(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'loginCount', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ], + 'transaction' => [ + 'id' => self::TRANSACTION + ] + ], + 'rows' => [ + [ + 0 + ] + ] + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'execute', 2); + $snippet->addLocal('database', $this->database); + $snippet->addUse(SessionPoolInterface::class); + + $res = $snippet->invoke('result'); + $this->assertInstanceOf(Result::class, $res->returnVal()); + $this->assertInstanceOf(Transaction::class, $res->returnVal()->transaction()); + } + + public function testRead() + { + $this->connection->read(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'loginCount', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'rows' => [ + [ + 0 + ] + ] + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'read'); + $snippet->addLocal('database', $this->database); + $snippet->addUse(KeySet::class); + + $res = $snippet->invoke('result'); + $this->assertInstanceOf(Result::class, $res->returnVal()); + } + + public function testReadWithSnapshot() + { + $this->connection->read(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'loginCount', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ], + 'transaction' => [ + 'id' => self::TRANSACTION + ] + ], + 'rows' => [ + [ + 0 + ] + ] + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'read', 1); + $snippet->addLocal('database', $this->database); + $snippet->addUse(KeySet::class); + + $res = $snippet->invoke('result'); + $this->assertInstanceOf(Result::class, $res->returnVal()); + $this->assertInstanceOf(Snapshot::class, $res->returnVal()->snapshot()); + } + + public function testReadWithTransaction() + { + $this->connection->read(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'loginCount', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ], + 'transaction' => [ + 'id' => self::TRANSACTION + ] + ], + 'rows' => [ + [ + 0 + ] + ] + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Database::class, 'read', 2); + $snippet->addLocal('database', $this->database); + $snippet->addUse(KeySet::class); + $snippet->addUse(SessionPoolInterface::class); + + $res = $snippet->invoke('result'); + $this->assertInstanceOf(Result::class, $res->returnVal()); + $this->assertInstanceOf(Transaction::class, $res->returnVal()->transaction()); + } + + public function testIam() + { + $snippet = $this->snippetFromMethod(Database::class, 'iam'); + $snippet->addLocal('database', $this->database); + + $res = $snippet->invoke('iam'); + $this->assertInstanceOf(Iam::class, $res->returnVal()); + } +} diff --git a/tests/snippets/Spanner/DateTest.php b/tests/snippets/Spanner/DateTest.php new file mode 100644 index 000000000000..96998c73fa27 --- /dev/null +++ b/tests/snippets/Spanner/DateTest.php @@ -0,0 +1,78 @@ +dt = new \DateTimeImmutable; + $this->date = new Date($this->dt); + } + + public function testClass() + { + $snippet = $this->snippetFromClass(Date::class); + $res = $snippet->invoke('date'); + + $this->assertInstanceOf(Date::class, $res->returnVal()); + } + + public function testClassString() + { + $snippet = $this->snippetFromClass(Date::class, 1); + $snippet->addLocal('date', $this->date); + $res = $snippet->invoke(); + + $this->assertEquals($this->date->formatAsString(), $res->output()); + } + + public function testGet() + { + $snippet = $this->snippetFromMethod(Date::class, 'get'); + $snippet->addLocal('date', $this->date); + $res = $snippet->invoke('dateTime'); + $this->assertEquals($this->dt, $res->returnVal()); + } + + public function testType() + { + $snippet = $this->snippetFromMethod(Date::class, 'type'); + $snippet->addLocal('date', $this->date); + $res = $snippet->invoke(); + $this->assertEquals(ValueMapper::TYPE_DATE, $res->output()); + } + + public function testFormatAsString() + { + $snippet = $this->snippetFromMethod(Date::class, 'formatAsString'); + $snippet->addLocal('date', $this->date); + $res = $snippet->invoke(); + $this->assertEquals($this->date->formatAsString(), $res->output()); + } +} diff --git a/tests/snippets/Spanner/DurationTest.php b/tests/snippets/Spanner/DurationTest.php new file mode 100644 index 000000000000..228c65463153 --- /dev/null +++ b/tests/snippets/Spanner/DurationTest.php @@ -0,0 +1,80 @@ +duration = new Duration(self::SECONDS, self::NANOS); + } + + public function testClass() + { + $snippet = $this->snippetFromClass(Duration::class); + $res = $snippet->invoke('duration'); + $this->assertInstanceOf(Duration::class, $res->returnVal()); + } + + public function testClassCast() + { + $snippet = $this->snippetFromClass(Duration::class, 1); + $snippet->addLocal('duration', $this->duration); + + $res = $snippet->invoke(); + $this->assertEquals($this->duration->formatAsString(), $res->output()); + } + + public function testGet() + { + $snippet = $this->snippetFromMethod(Duration::class, 'get'); + $snippet->addLocal('duration', $this->duration); + + $res = $snippet->invoke('res'); + $this->assertEquals($this->duration->get(), $res->returnVal()); + } + + public function testType() + { + $snippet = $this->snippetFromMethod(Duration::class, 'type'); + $snippet->addLocal('duration', $this->duration); + + $res = $snippet->invoke(); + $this->assertEquals(Duration::TYPE, $res->output()); + } + + public function testFormatAsString() + { + $snippet = $this->snippetFromMethod(Duration::class, 'formatAsString'); + $snippet->addLocal('duration', $this->duration); + + $res = $snippet->invoke(); + $this->assertEquals($this->duration->formatAsString(), $res->output()); + } +} diff --git a/tests/snippets/Spanner/InstanceTest.php b/tests/snippets/Spanner/InstanceTest.php new file mode 100644 index 000000000000..b5bb836df759 --- /dev/null +++ b/tests/snippets/Spanner/InstanceTest.php @@ -0,0 +1,222 @@ +connection = $this->prophesize(ConnectionInterface::class); + $this->instance = \Google\Cloud\Dev\stub(Instance::class, [ + $this->connection->reveal(), + $this->prophesize(SessionPoolInterface::class)->reveal(), + $this->prophesize(LongRunningConnectionInterface::class)->reveal(), + [], + self::PROJECT, + self::INSTANCE + ]); + } + + public function testClass() + { + $snippet = $this->snippetFromClass(Instance::class); + $res = $snippet->invoke('instance'); + $this->assertInstanceOf(Instance::class, $res->returnVal()); + $this->assertEquals(self::INSTANCE, $res->returnVal()->name()); + } + + public function testName() + { + $snippet = $this->snippetFromMethod(Instance::class, 'name'); + $snippet->addLocal('instance', $this->instance); + + $res = $snippet->invoke('name'); + $this->assertEquals(self::INSTANCE, $res->returnVal()); + } + + public function testInfo() + { + $snippet = $this->snippetFromMethod(Instance::class, 'info'); + $snippet->addLocal('instance', $this->instance); + + $this->connection->getInstance(Argument::any()) + ->shouldBeCalled() + ->willReturn(['nodeCount' => 1]); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke(); + $this->assertEquals('1', $res->output()); + } + + public function testExists() + { + $snippet = $this->snippetFromMethod(Instance::class, 'exists'); + $snippet->addLocal('instance', $this->instance); + + $this->connection->getInstance(Argument::any()) + ->shouldBeCalled() + ->willReturn(['foo' => 'bar']); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke(); + $this->assertEquals('Instance exists!', $res->output()); + } + + public function testReload() + { + $snippet = $this->snippetFromMethod(Instance::class, 'reload'); + $snippet->addLocal('instance', $this->instance); + + $this->connection->getInstance(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn(['nodeCount' => 1]); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('info'); + $info = $this->instance->info(); + $this->assertEquals($info, $res->returnVal()); + } + + public function testState() + { + $snippet = $this->snippetFromMethod(Instance::class, 'state'); + $snippet->addLocal('instance', $this->instance); + $snippet->addUse(Instance::class); + + $this->connection->getInstance(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn(['state' => Instance::STATE_READY]); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke(); + $this->assertEquals('Instance is ready!', $res->output()); + } + + public function testUpdate() + { + $snippet = $this->snippetFromMethod(Instance::class, 'update'); + $snippet->addLocal('instance', $this->instance); + + $this->connection->getInstance(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn([ + 'displayName' => 'foo', + 'nodeCount' => 1 + ]); + + $this->connection->updateInstance(Argument::any()) + ->shouldBeCalled(); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + $snippet->invoke(); + } + + public function testDelete() + { + $snippet = $this->snippetFromMethod(Instance::class, 'delete'); + $snippet->addLocal('instance', $this->instance); + + $this->connection->deleteInstance(Argument::any()) + ->shouldBeCalled(); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + $snippet->invoke(); + } + + public function testCreateDatabase() + { + $snippet = $this->snippetFromMethod(Instance::class, 'createDatabase'); + $snippet->addLocal('instance', $this->instance); + + $this->connection->createDatabase(Argument::any()) + ->shouldBeCalled(); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('database'); + $this->assertInstanceOf(LongRunningOperation::class, $res->returnVal()); + } + + public function testDatabase() + { + $snippet = $this->snippetFromMethod(Instance::class, 'database'); + $snippet->addLocal('instance', $this->instance); + + $res = $snippet->invoke('database'); + $this->assertInstanceOf(Database::class, $res->returnVal()); + $this->assertEquals(self::DATABASE, $res->returnVal()->name()); + } + + public function testDatabases() + { + $snippet = $this->snippetFromMethod(Instance::class, 'databases'); + $snippet->addLocal('instance', $this->instance); + + $this->connection->listDatabases(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'databases' => [ + [ + 'name' => 'projects/'. self::PROJECT .'/instances/'. self::INSTANCE .'/databases/'. self::DATABASE + ] + ] + ]); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('databases'); + + $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); + $this->assertInstanceOf(Database::class, $res->returnVal()->current()); + } + + public function testIam() + { + $snippet = $this->snippetFromMethod(Instance::class, 'iam'); + $snippet->addLocal('instance', $this->instance); + + $res = $snippet->invoke('iam'); + $this->assertInstanceOf(Iam::class, $res->returnVal()); + } +} diff --git a/tests/snippets/Spanner/KeyRangeTest.php b/tests/snippets/Spanner/KeyRangeTest.php new file mode 100644 index 000000000000..869d3694a6bb --- /dev/null +++ b/tests/snippets/Spanner/KeyRangeTest.php @@ -0,0 +1,95 @@ +range = new KeyRange; + } + + public function testClass() + { + $snippet = $this->snippetFromClass(KeyRange::class); + $snippet->addUse(KeyRange::class); + $res = $snippet->invoke('range'); + $this->assertInstanceOf(KeyRange::class, $res->returnVal()); + } + + public function testStart() + { + $this->range->setStart(KeyRange::TYPE_OPEN, ['Bob']); + + $snippet = $this->snippetFromMethod(KeyRange::class, 'start'); + $snippet->addLocal('range', $this->range); + $res = $snippet->invoke('start'); + $this->assertEquals(['Bob'], $res->returnVal()); + } + + public function testSetStart() + { + $snippet = $this->snippetFromMethod(KeyRange::class, 'setStart'); + $snippet->addLocal('range', $this->range); + $snippet->addUse(KeyRange::class); + $res = $snippet->invoke(); + $this->assertEquals(['Bob'], $this->range->start()); + } + + public function testEnd() + { + $this->range->setEnd(KeyRange::TYPE_CLOSED, ['Jill']); + + $snippet = $this->snippetFromMethod(KeyRange::class, 'end'); + $snippet->addLocal('range', $this->range); + $res = $snippet->invoke('end'); + $this->assertEquals(['Jill'], $res->returnVal()); + } + + public function testSetEnd() + { + $snippet = $this->snippetFromMethod(KeyRange::class, 'setEnd'); + $snippet->addLocal('range', $this->range); + $snippet->addUse(KeyRange::class); + $res = $snippet->invoke(); + $this->assertEquals(['Jill'], $this->range->end()); + } + + public function testTypes() + { + $this->range->setStart(KeyRange::TYPE_OPEN, ['foo']); + $this->range->setEnd(KeyRange::TYPE_OPEN, ['foo']); + + $snippet = $this->snippetFromMethod(KeyRange::class, 'types'); + $snippet->addLocal('range', $this->range); + + $res = $snippet->invoke('types'); + $this->assertEquals([ + 'start' => 'startOpen', + 'end' => 'endOpen' + ], $res->returnVal()); + } +} diff --git a/tests/snippets/Spanner/KeySetTest.php b/tests/snippets/Spanner/KeySetTest.php new file mode 100644 index 000000000000..c7a2904c53f6 --- /dev/null +++ b/tests/snippets/Spanner/KeySetTest.php @@ -0,0 +1,134 @@ +keyset = new KeySet(); + $this->range = new KeyRange(); + } + + public function testClass() + { + $snippet = $this->snippetFromClass(KeySet::class); + $res = $snippet->invoke('keySet'); + $this->assertInstanceOf(KeySet::class, $res->returnVal()); + } + + public function testRanges() + { + $this->keyset->addRange($this->range); + + $snippet = $this->snippetFromMethod(KeySet::class, 'ranges'); + $snippet->addLocal('keySet', $this->keyset); + + $res = $snippet->invoke('ranges'); + $this->assertEquals($this->range, $res->returnVal()[0]); + } + + public function testAddRange() + { + $snippet = $this->snippetFromMethod(KeySet::class, 'addRange'); + $snippet->addLocal('keySet', $this->keyset); + $snippet->addUse(KeyRange::class); + + $this->assertEmpty($this->keyset->ranges()); + $res = $snippet->invoke(); + $this->assertContainsOnly(KeyRange::class, $this->keyset->ranges()); + } + + public function testSetRanges() + { + $snippet = $this->snippetFromMethod(KeySet::class, 'setRanges'); + $snippet->addLocal('keySet', $this->keyset); + $snippet->addUse(KeyRange::class); + + $this->assertEmpty($this->keyset->ranges()); + $res = $snippet->invoke(); + $this->assertContainsOnly(KeyRange::class, $this->keyset->ranges()); + } + + public function testKeys() + { + $snippet = $this->snippetFromMethod(KeySet::class, 'keys'); + $snippet->addLocal('keySet', $this->keyset); + + $this->keyset->addKey('foo'); + + $res = $snippet->invoke('keys'); + $this->assertEquals('foo', $res->returnVal()[0]); + } + + public function testAddKey() + { + $snippet = $this->snippetFromMethod(KeySet::class, 'addKey'); + $snippet->addLocal('keySet', $this->keyset); + + $this->assertEmpty($this->keyset->keys()); + + $res = $snippet->invoke(); + $this->assertEquals(1, count($this->keyset->keys())); + } + + public function testSetKeys() + { + $snippet = $this->snippetFromMethod(KeySet::class, 'setKeys'); + $snippet->addLocal('keySet', $this->keyset); + + $this->assertEmpty($this->keyset->keys()); + + $res = $snippet->invoke(); + $this->assertEquals(2, count($this->keyset->keys())); + } + + public function testMatchAll() + { + $snippet = $this->snippetFromMethod(KeySet::class, 'matchAll'); + $snippet->addLocal('keySet', $this->keyset); + + $this->assertEmpty($snippet->invoke()->output()); + + $this->keyset->setMatchAll(true); + + $this->assertEquals('All keys will match', $snippet->invoke()->output()); + } + + public function testSetMatchAll() + { + $snippet = $this->snippetFromMethod(KeySet::class, 'setMatchAll'); + $snippet->addLocal('keySet', $this->keyset); + + $this->assertFalse($this->keyset->matchAll()); + + $snippet->invoke(); + + $this->assertTrue($this->keyset->matchAll()); + } +} diff --git a/tests/snippets/Spanner/SnapshotTest.php b/tests/snippets/Spanner/SnapshotTest.php new file mode 100644 index 000000000000..86d7ed889725 --- /dev/null +++ b/tests/snippets/Spanner/SnapshotTest.php @@ -0,0 +1,167 @@ +connection = $this->prophesize(ConnectionInterface::class); + $operation = $this->prophesize(Operation::class); + $session = $this->prophesize(Session::class); + + $this->snapshot = \Google\Cloud\Dev\stub(Snapshot::class, [ + $operation->reveal(), + $session->reveal(), + self::TRANSACTION, + new Timestamp(new \DateTime) + ], ['operation']); + } + + private function stubOperation($stub = null) + { + $operation = \Google\Cloud\Dev\stub(Operation::class, [ + $this->connection->reveal(), false + ]); + + if (!$stub) { + $stub = $this->snapshot; + } + + $stub->___setProperty('operation', $operation); + } + + public function testClass() + { + $database = $this->prophesize(Database::class); + $database->snapshot()->shouldBeCalled()->willReturn('foo'); + + $snippet = $this->snippetFromClass(Snapshot::class); + $snippet->replace('$database =', '//$database ='); + $snippet->addLocal('database', $database->reveal()); + + $res = $snippet->invoke('snapshot'); + $this->assertEquals('foo', $res->returnVal()); + } + + public function testExecute() + { + $this->connection->executeSql(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'loginCount', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'rows' => [ + [ + 0 + ] + ] + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMagicMethod(Snapshot::class, 'execute'); + $snippet->addLocal('snapshot', $this->snapshot); + $res = $snippet->invoke('result'); + + $this->assertInstanceOf(Result::class, $res->returnVal()); + } + + public function testRead() + { + $this->connection->read(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'loginCount', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'rows' => [ + [ + 0 + ] + ] + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMagicMethod(Snapshot::class, 'read'); + $snippet->addLocal('snapshot', $this->snapshot); + $snippet->addUse(KeySet::class); + $res = $snippet->invoke('result'); + + $this->assertInstanceOf(Result::class, $res->returnVal()); + } + + public function testId() + { + $snippet = $this->snippetFromMagicMethod(Snapshot::class, 'id'); + $snippet->addLocal('snapshot', $this->snapshot); + + $res = $snippet->invoke('id'); + $this->assertEquals(self::TRANSACTION, $res->returnVal()); + } + + public function testReadTimestamp() + { + $snippet = $this->snippetFromMethod(Snapshot::class, 'readTimestamp'); + $snippet->addLocal('snapshot', $this->snapshot); + + $res = $snippet->invoke('timestamp'); + $this->assertInstanceOf(Timestamp::class, $res->returnVal()); + } +} diff --git a/tests/snippets/Spanner/SpannerClientTest.php b/tests/snippets/Spanner/SpannerClientTest.php new file mode 100644 index 000000000000..b715cf0f262f --- /dev/null +++ b/tests/snippets/Spanner/SpannerClientTest.php @@ -0,0 +1,273 @@ +connection = $this->prophesize(ConnectionInterface::class); + $this->client = \Google\Cloud\Dev\stub(SpannerClient::class); + $this->client->___setProperty('connection', $this->connection->reveal()); + } + + public function testClass() + { + $snippet = $this->snippetFromClass(SpannerClient::class); + $res = $snippet->invoke('spanner'); + $this->assertInstanceOf(SpannerClient::class, $res->returnVal()); + } + + /** + * @group spanneradmin + */ + public function testConfigurations() + { + $this->connection->listConfigs(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'instanceConfigs' => [ + ['name' => 'projects/my-awesome-projects/instanceConfigs/Foo'], + ['name' => 'projects/my-awesome-projects/instanceConfigs/Bar'], + ] + ]); + + $this->client->___setProperty('connection', $this->connection->reveal()); + + $snippet = $this->snippetFromMethod(SpannerClient::class, 'configurations'); + $snippet->addLocal('spanner', $this->client); + + $res = $snippet->invoke('configurations'); + + $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); + $this->assertInstanceOf(Configuration::class, $res->returnVal()->current()); + $this->assertEquals('Foo', $res->returnVal()->current()->name()); + } + + /** + * @group spanneradmin + */ + public function testConfiguration() + { + $configName = 'foo'; + + $snippet = $this->snippetFromMethod(SpannerClient::class, 'configuration'); + $snippet->addLocal('spanner', $this->client); + $snippet->addLocal('configurationName', self::CONFIG); + + $res = $snippet->invoke('configuration'); + $this->assertInstanceOf(Configuration::class, $res->returnVal()); + $this->assertEquals(self::CONFIG, $res->returnVal()->name()); + } + + /** + * @group spanneradmin + */ + public function testCreateInstance() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'createInstance'); + $snippet->addLocal('spanner', $this->client); + $snippet->addLocal('configuration', $this->client->configuration(self::CONFIG)); + + $this->connection->createInstance(Argument::any()) + ->shouldBeCalled() + ->willReturn(['name' => 'operations/foo']); + + $this->client->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('operation'); + $this->assertInstanceOf(LongRunningOperation::class, $res->returnVal()); + } + + /** + * @group spanneradmin + */ + public function testInstance() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'instance'); + $snippet->addLocal('spanner', $this->client); + + $res = $snippet->invoke('instance'); + $this->assertInstanceOf(Instance::class, $res->returnVal()); + $this->assertEquals(self::INSTANCE, $res->returnVal()->name()); + } + + /** + * @group spanneradmin + */ + public function testInstances() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'instances'); + $snippet->addLocal('spanner', $this->client); + + $this->connection->listInstances(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'instances' => [ + ['name' => 'projects/my-awesome-project/instances/'. self::INSTANCE], + ['name' => 'projects/my-awesome-project/instances/Bar'] + ] + ]); + + $this->client->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('instances'); + $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); + $this->assertInstanceOf(Instance::class, $res->returnVal()->current()); + $this->assertEquals(self::INSTANCE, $res->returnVal()->current()->name()); + } + + public function testConnect() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'connect'); + $snippet->addLocal('spanner', $this->client); + + $res = $snippet->invoke('database'); + $this->assertInstanceOf(Database::class, $res->returnVal()); + } + + public function testKeySet() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'keySet'); + $snippet->addLocal('spanner', $this->client); + + $res = $snippet->invoke('keySet'); + $this->assertInstanceOf(KeySet::class, $res->returnVal()); + } + + public function testKeySetAll() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'keySet', 1); + $snippet->addLocal('spanner', $this->client); + + $res = $snippet->invoke('keySet'); + $this->assertInstanceOf(KeySet::class, $res->returnVal()); + $this->assertTrue($res->returnVal()->matchAll()); + } + + public function testKeyRange() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'keyRange'); + $snippet->addLocal('spanner', $this->client); + + $res = $snippet->invoke('range'); + $this->assertInstanceOf(KeyRange::class, $res->returnVal()); + } + + public function testKeyRangeComplete() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'keyRange', 1); + $snippet->addLocal('spanner', $this->client); + $snippet->addUse(KeyRange::class); + + $res = $snippet->invoke('range'); + $this->assertInstanceOf(KeyRange::class, $res->returnVal()); + $res->returnVal()->keyRangeObject(); + } + + public function testBytes() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'bytes'); + $snippet->addLocal('spanner', $this->client); + + $res = $snippet->invoke('bytes'); + $this->assertInstanceOf(Bytes::class, $res->returnVal()); + } + + public function testDate() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'date'); + $snippet->addLocal('spanner', $this->client); + + $res = $snippet->invoke('date'); + $this->assertInstanceOf(Date::class, $res->returnVal()); + } + + public function testTimestamp() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'timestamp'); + $snippet->addLocal('spanner', $this->client); + + $res = $snippet->invoke('timestamp'); + $this->assertInstanceOf(Timestamp::class, $res->returnVal()); + } + + public function testInt64() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'int64'); + $snippet->addLocal('spanner', $this->client); + + $res = $snippet->invoke('int64'); + $this->assertInstanceOf(Int64::class, $res->returnVal()); + } + + public function testDuration() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'duration'); + $snippet->addLocal('spanner', $this->client); + + $res = $snippet->invoke('duration'); + $this->assertInstanceOf(Duration::class, $res->returnVal()); + } + + public function testSessionClient() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'sessionClient'); + $snippet->addLocal('spanner', $this->client); + + $res = $snippet->invoke('sessionClient'); + $this->assertInstanceOf(SessionClient::class, $res->returnVal()); + } + + public function testResumeOperation() + { + $snippet = $this->snippetFromMethod(SpannerClient::class, 'resumeOperation'); + $snippet->addLocal('spanner', $this->client); + $snippet->addLocal('operationName', 'operations/foo'); + + $res = $snippet->invoke('operation'); + $this->assertInstanceOf(LongRunningOperation::class, $res->returnVal()); + } +} diff --git a/tests/snippets/Spanner/TimestampTest.php b/tests/snippets/Spanner/TimestampTest.php new file mode 100644 index 000000000000..a96512fe0d11 --- /dev/null +++ b/tests/snippets/Spanner/TimestampTest.php @@ -0,0 +1,79 @@ +dt = new \DateTime; + $this->timestamp = new Timestamp($this->dt); + } + + public function testClass() + { + $snippet = $this->snippetFromClass(Timestamp::class); + $res = $snippet->invoke('timestamp'); + $this->assertInstanceOf(Timestamp::class, $res->returnVal()); + } + + public function testClassCast() + { + $snippet = $this->snippetFromClass(Timestamp::class, 1); + $snippet->addLocal('timestamp', $this->timestamp); + + $res = $snippet->invoke(); + $this->assertEquals((string)$this->timestamp, $res->output()); + } + + public function testGet() + { + $snippet = $this->snippetFromMethod(Timestamp::class, 'get'); + $snippet->addLocal('timestamp', $this->timestamp); + + $res = $snippet->invoke('dateTime'); + $this->assertEquals($this->dt, $res->returnVal()); + } + + public function testType() + { + $snippet = $this->snippetFromMethod(Timestamp::class, 'type'); + $snippet->addLocal('timestamp', $this->timestamp); + + $res = $snippet->invoke('type'); + $this->assertEquals(ValueMapper::TYPE_TIMESTAMP, $res->returnVal()); + } + + public function testFormatAsString() + { + $snippet = $this->snippetFromMethod(Timestamp::class, 'formatAsString'); + $snippet->addLocal('timestamp', $this->timestamp); + + $res = $snippet->invoke('value'); + $this->assertEquals($this->timestamp->formatAsString(), $res->returnVal()); + } +} diff --git a/tests/snippets/Spanner/TransactionTest.php b/tests/snippets/Spanner/TransactionTest.php new file mode 100644 index 000000000000..f0733e22545d --- /dev/null +++ b/tests/snippets/Spanner/TransactionTest.php @@ -0,0 +1,330 @@ +connection = $this->prophesize(ConnectionInterface::class); + $operation = $this->prophesize(Operation::class); + $session = $this->prophesize(Session::class); + + $this->transaction = \Google\Cloud\Dev\stub(Transaction::class, [ + $operation->reveal(), + $session->reveal(), + self::TRANSACTION + ], ['operation']); + } + + private function stubOperation($stub = null) + { + $operation = \Google\Cloud\Dev\stub(Operation::class, [ + $this->connection->reveal(), false + ]); + + if (!$stub) { + $stub = $this->transaction; + } + + $stub->___setProperty('operation', $operation); + } + + public function testClass() + { + $database = $this->prophesize(Database::class); + $database->runTransaction(Argument::type('callable'))->shouldBeCalled(); + + $snippet = $this->snippetFromClass(Transaction::class); + $snippet->replace('$database =', '//$database ='); + $snippet->addLocal('database', $database->reveal()); + + $res = $snippet->invoke(); + } + + public function testClassReturnTransaction() + { + $database = $this->prophesize(Database::class); + $database->transaction() + ->shouldBeCalled() + ->willReturn('foo'); + + $snippet = $this->snippetFromClass(Transaction::class, 1); + $snippet->addLocal('database', $database->reveal()); + $res = $snippet->invoke('transaction'); + $this->assertEquals('foo', $res->returnVal()); + } + + public function testExecute() + { + $this->connection->executeSql(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'loginCount', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'rows' => [ + [ + 0 + ] + ] + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMagicMethod(Transaction::class, 'execute'); + $snippet->addLocal('transaction', $this->transaction); + $res = $snippet->invoke('result'); + + $this->assertInstanceOf(Result::class, $res->returnVal()); + } + + public function testRead() + { + $this->connection->read(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'loginCount', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'rows' => [ + [ + 0 + ] + ] + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMagicMethod(Transaction::class, 'read'); + $snippet->addLocal('transaction', $this->transaction); + $snippet->addUse(KeySet::class); + $res = $snippet->invoke('result'); + + $this->assertInstanceOf(Result::class, $res->returnVal()); + } + + public function testId() + { + $snippet = $this->snippetFromMagicMethod(Transaction::class, 'id'); + $snippet->addLocal('transaction', $this->transaction); + + $res = $snippet->invoke('id'); + $this->assertEquals(self::TRANSACTION, $res->returnVal()); + } + + public function testInsert() + { + $snippet = $this->snippetFromMethod(Transaction::class, 'insert'); + $snippet->addLocal('transaction', $this->transaction); + + $this->stubOperation(); + + $res = $snippet->invoke(); + + $mutations = $this->transaction->___getProperty('mutations'); + $this->assertTrue(isset($mutations[0]['insert'])); + } + + + public function testInsertBatch() + { + $snippet = $this->snippetFromMethod(Transaction::class, 'insertBatch'); + $snippet->addLocal('transaction', $this->transaction); + + $this->stubOperation(); + + $res = $snippet->invoke(); + + $mutations = $this->transaction->___getProperty('mutations'); + $this->assertTrue(isset($mutations[0]['insert'])); + } + + public function testUpdate() + { + $snippet = $this->snippetFromMethod(Transaction::class, 'update'); + $snippet->addLocal('transaction', $this->transaction); + + $this->stubOperation(); + + $res = $snippet->invoke(); + + $mutations = $this->transaction->___getProperty('mutations'); + $this->assertTrue(isset($mutations[0]['update'])); + } + + + public function testUpdateBatch() + { + $snippet = $this->snippetFromMethod(Transaction::class, 'updateBatch'); + $snippet->addLocal('transaction', $this->transaction); + + $this->stubOperation(); + + $res = $snippet->invoke(); + + $mutations = $this->transaction->___getProperty('mutations'); + $this->assertTrue(isset($mutations[0]['update'])); + } + + public function testInsertOrUpdate() + { + $snippet = $this->snippetFromMethod(Transaction::class, 'insertOrUpdate'); + $snippet->addLocal('transaction', $this->transaction); + + $this->stubOperation(); + + $res = $snippet->invoke(); + + $mutations = $this->transaction->___getProperty('mutations'); + $this->assertTrue(isset($mutations[0]['insertOrUpdate'])); + } + + + public function testInsertOrUpdateBatch() + { + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Transaction::class, 'insertOrUpdateBatch'); + $snippet->addLocal('transaction', $this->transaction); + + $this->stubOperation(); + + $res = $snippet->invoke(); + + $mutations = $this->transaction->___getProperty('mutations'); + $this->assertTrue(isset($mutations[0]['insertOrUpdate'])); + } + + public function testReplace() + { + $snippet = $this->snippetFromMethod(Transaction::class, 'replace'); + $snippet->addLocal('transaction', $this->transaction); + + $this->stubOperation(); + + $res = $snippet->invoke(); + + $mutations = $this->transaction->___getProperty('mutations'); + $this->assertTrue(isset($mutations[0]['replace'])); + } + + + public function testReplaceBatch() + { + $snippet = $this->snippetFromMethod(Transaction::class, 'replaceBatch'); + $snippet->addLocal('transaction', $this->transaction); + + $this->stubOperation(); + + $res = $snippet->invoke(); + + $mutations = $this->transaction->___getProperty('mutations'); + $this->assertTrue(isset($mutations[0]['replace'])); + } + + public function testDelete() + { + $snippet = $this->snippetFromMethod(Transaction::class, 'delete'); + $snippet->addUse(KeySet::class); + $snippet->addLocal('transaction', $this->transaction); + + $this->stubOperation(); + + $res = $snippet->invoke(); + + $mutations = $this->transaction->___getProperty('mutations'); + $this->assertTrue(isset($mutations[0]['delete'])); + } + + public function testRollback() + { + $this->connection->rollback(Argument::any()) + ->shouldBeCalled(); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Transaction::class, 'rollback'); + $snippet->addLocal('transaction', $this->transaction); + + $snippet->invoke(); + } + + public function testCommit() + { + $this->connection->commit(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'commitTimestamp' => (new Timestamp(new \DateTime))->formatAsString() + ]); + + $this->stubOperation(); + + $snippet = $this->snippetFromMethod(Transaction::class, 'commit'); + $snippet->addLocal('transaction', $this->transaction); + + $snippet->invoke(); + } + + public function testState() + { + $snippet = $this->snippetFromMethod(Transaction::class, 'state'); + $snippet->addLocal('transaction', $this->transaction); + + $res = $snippet->invoke('state'); + $this->assertEquals(Transaction::STATE_ACTIVE, $res->returnVal()); + } +} diff --git a/tests/snippets/Speech/OperationTest.php b/tests/snippets/Speech/OperationTest.php index 006c823c05e0..e6f06e449e05 100644 --- a/tests/snippets/Speech/OperationTest.php +++ b/tests/snippets/Speech/OperationTest.php @@ -48,7 +48,11 @@ public function setUp() ]; $this->connection = $this->prophesize(ConnectionInterface::class); - $this->operation = new \SpeechOperationStub($this->connection->reveal(), $this->opData['name'], $this->opData); + $this->operation = \Google\Cloud\Dev\stub(Operation::class, [ + $this->connection->reveal(), + $this->opData['name'], + $this->opData + ]); } public function testClass() @@ -118,7 +122,7 @@ public function testReload() ->shouldBeCalled() ->willReturn($this->opData); - $this->operation->setConnection($this->connection->reveal()); + $this->operation->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals(print_r($this->opData['response'], true), $res->output()); diff --git a/tests/snippets/Speech/SpeechClientTest.php b/tests/snippets/Speech/SpeechClientTest.php index b6c167b4a6e8..03c6fd70c636 100644 --- a/tests/snippets/Speech/SpeechClientTest.php +++ b/tests/snippets/Speech/SpeechClientTest.php @@ -36,8 +36,8 @@ public function setUp() { $this->testFile = "'" . __DIR__ . '/../fixtures/Speech/demo.flac' . "'"; $this->connection = $this->prophesize(ConnectionInterface::class); - $this->client = new \SpeechClientStub(['languageCode' => 'en-US']); - $this->client->setConnection($this->connection->reveal()); + $this->client = \Google\Cloud\Dev\stub(SpeechClient::class, ['languageCode' => 'en-US']); + $this->client->___setProperty('connection', $this->connection->reveal()); } public function testClass() @@ -69,7 +69,7 @@ public function testRecognize() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals($transcript . PHP_EOL, $res->output()); @@ -96,7 +96,7 @@ public function testRecognizeWithOptions() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals($transcript . PHP_EOL, $res->output()); @@ -127,7 +127,7 @@ public function testBeginRecognizeOperation() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals(print_r($results[0]['alternatives'][0], true), $res->output()); @@ -158,7 +158,7 @@ public function testBeginRecognizeOperationWithOptions() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals(print_r($results[0]['alternatives'][0], true), $res->output()); diff --git a/tests/snippets/Storage/AclTest.php b/tests/snippets/Storage/AclTest.php index a81f9ea4f0c8..0a34b035a685 100644 --- a/tests/snippets/Storage/AclTest.php +++ b/tests/snippets/Storage/AclTest.php @@ -33,7 +33,11 @@ class AclTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->acl = new \AclStub($this->connection->reveal(), 'bucketAccessControls', []); + $this->acl = \Google\Cloud\Dev\stub(Acl::class, [ + $this->connection->reveal(), + 'bucketAccessControls', + [] + ]); } public function testClass() { @@ -51,7 +55,7 @@ public function testDelete() $this->connection->deleteAcl(Argument::any()) ->shouldBeCalled(); - $this->acl->setConnection($this->connection->reveal()); + $this->acl->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -65,7 +69,7 @@ public function testGet() ->shouldBeCalled() ->willReturn('foo'); - $this->acl->setConnection($this->connection->reveal()); + $this->acl->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('res'); $this->assertEquals('foo', $res->returnVal()); @@ -79,7 +83,7 @@ public function testAdd() $this->connection->insertAcl(Argument::any()) ->shouldBecalled(); - $this->acl->setConnection($this->connection->reveal()); + $this->acl->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -92,7 +96,7 @@ public function testUpdate() $this->connection->patchAcl(Argument::any()) ->shouldBeCalled(); - $this->acl->setConnection($this->connection->reveal()); + $this->acl->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } } diff --git a/tests/snippets/Storage/BucketTest.php b/tests/snippets/Storage/BucketTest.php index 5340699587ae..18fe1d01319d 100644 --- a/tests/snippets/Storage/BucketTest.php +++ b/tests/snippets/Storage/BucketTest.php @@ -43,7 +43,10 @@ class BucketTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->bucket = new \BucketStub($this->connection->reveal(), self::BUCKET); + $this->bucket = \Google\Cloud\Dev\stub(Bucket::class, [ + $this->connection->reveal(), + self::BUCKET + ]); } public function testClass() @@ -82,7 +85,7 @@ public function testExists() $this->connection->getBucket(Argument::any()) ->shouldBeCalled(); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('Bucket exists!', $res->output()); @@ -106,7 +109,7 @@ public function testUpload() ->shouldBeCalled() ->willReturn($uploader->reveal()); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('object'); $this->assertInstanceOf(StorageObject::class, $res->returnVal()); @@ -133,7 +136,7 @@ public function testUploadResumableUploader() ->shouldBeCalled() ->willReturn($uploader->reveal()); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('object'); $this->assertInstanceOf(StorageObject::class, $res->returnVal()); @@ -160,7 +163,7 @@ public function testUploadEncryption() ->shouldBeCalled() ->willReturn($uploader->reveal()); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('object'); $this->assertInstanceOf(StorageObject::class, $res->returnVal()); @@ -194,7 +197,7 @@ public function testGetResumableUploader() ->shouldBeCalled() ->willReturn($uploader->reveal()); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('object'); } @@ -214,7 +217,7 @@ public function testGetStreamableUploader() ->shouldBeCalled() ->willReturn($uploader->reveal()); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -248,7 +251,7 @@ public function testObjects() ] ]); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('objects'); $this->assertInstanceOf(ObjectIterator::class, $res->returnVal()); @@ -264,7 +267,7 @@ public function testDelete() $this->connection->deleteBucket(Argument::any()) ->shouldBeCalled(); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -283,7 +286,7 @@ public function testUpdate() ->shouldBeCalled() ->willReturn('foo'); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); } @@ -300,7 +303,7 @@ public function testCompose() 'generation' => 'foo' ]); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('singleObject'); $this->assertInstanceOf(StorageObject::class, $res->returnVal()); @@ -319,7 +322,7 @@ public function testComposeWithObjects() 'generation' => 'foo' ]); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('singleObject'); $this->assertInstanceOf(StorageObject::class, $res->returnVal()); @@ -338,7 +341,7 @@ public function testInfo() 'location' => $loc ]); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals($loc, $res->output()); @@ -356,7 +359,7 @@ public function testReload() 'location' => $loc ]); - $this->bucket->setConnection($this->connection->reveal()); + $this->bucket->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals($loc, $res->output()); diff --git a/tests/snippets/Storage/StorageClientTest.php b/tests/snippets/Storage/StorageClientTest.php index 906f2cd4aba6..0f5d98583a69 100644 --- a/tests/snippets/Storage/StorageClientTest.php +++ b/tests/snippets/Storage/StorageClientTest.php @@ -37,8 +37,8 @@ class StorageClientTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->client = new \StorageClientStub; - $this->client->setConnection($this->connection->reveal()); + $this->client = \Google\Cloud\Dev\stub(StorageClient::class); + $this->client->___setProperty('connection', $this->connection->reveal()); } public function testClass() @@ -71,7 +71,7 @@ public function testBuckets() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('buckets'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); @@ -95,7 +95,7 @@ public function testBucketsWithPrefix() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('buckets'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); @@ -112,7 +112,7 @@ public function testCreateBucket() ->shouldBeCalled() ->willReturn([]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('bucket'); $this->assertInstanceOf(Bucket::class, $res->returnVal()); @@ -127,9 +127,9 @@ public function testCreateBucketWithLogging() ->shouldBeCalled() ->willReturn([]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('bucket'); $this->assertInstanceOf(Bucket::class, $res->returnVal()); } -} + } diff --git a/tests/snippets/Storage/StorageObjectTest.php b/tests/snippets/Storage/StorageObjectTest.php index 0cd7f6e5fdd6..475ee81e9baa 100644 --- a/tests/snippets/Storage/StorageObjectTest.php +++ b/tests/snippets/Storage/StorageObjectTest.php @@ -40,11 +40,11 @@ class StorageObjectTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->object = new \StorageObjectStub( + $this->object = \Google\Cloud\Dev\stub(StorageObject::class, [ $this->connection->reveal(), self::OBJECT, self::BUCKET - ); + ]); } public function testClass() @@ -72,7 +72,7 @@ public function testExists() ->shouldBeCalled() ->willReturn([]); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('Object exists!', $res->output()); @@ -86,7 +86,7 @@ public function testDelete() $this->connection->deleteObject(Argument::any()) ->shouldBeCalled(); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -99,7 +99,7 @@ public function testUpdate() $this->connection->patchObject(Argument::any()) ->shouldBeCalled(); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -117,7 +117,7 @@ public function testCopy() 'generation' => 'foo' ]); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('copiedObject'); $this->assertInstanceOf(StorageObject::class, $res->returnVal()); @@ -144,7 +144,7 @@ public function testCopyToBucket() 'generation' => 'foo' ]); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('copiedObject'); $this->assertInstanceOf(StorageObject::class, $res->returnVal()); @@ -165,7 +165,7 @@ public function testRewrite() ] ]); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('rewrittenObject'); $this->assertInstanceOf(StorageObject::class, $res->returnVal()); @@ -194,7 +194,7 @@ public function testRewriteNewObjectName() ] ]); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('rewrittenObject'); $this->assertInstanceOf(StorageObject::class, $res->returnVal()); @@ -216,7 +216,7 @@ public function testRewriteNewKey() ] ]); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('rewrittenObject'); $this->assertInstanceOf(StorageObject::class, $res->returnVal()); @@ -238,7 +238,7 @@ public function testRename() $this->connection->deleteObject(Argument::any()) ->shouldBeCalled(); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('object2.txt', $res->output()); @@ -253,7 +253,7 @@ public function testDownloadAsString() ->shouldBeCalled() ->willReturn(\GuzzleHttp\Psr7\stream_for('test')); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('test', $res->output()); @@ -269,7 +269,7 @@ public function testDownloadToFile() ->shouldBeCalled() ->willReturn(\GuzzleHttp\Psr7\stream_for('test')); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('stream'); @@ -285,7 +285,7 @@ public function testDownloadAsStream() ->shouldBeCalled() ->willReturn(\GuzzleHttp\Psr7\stream_for('test')); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); @@ -306,7 +306,7 @@ public function testInfo() 'location' => 'right behind you!' ]); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('1', $res->output()); @@ -326,7 +326,7 @@ public function testReload() 'location' => 'right behind you!' ]); - $this->object->setConnection($this->connection->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('right behind you!', $res->output()); diff --git a/tests/snippets/Translate/TranslateClientTest.php b/tests/snippets/Translate/TranslateClientTest.php index 7f748e1ac0b2..00ba0252d86f 100644 --- a/tests/snippets/Translate/TranslateClientTest.php +++ b/tests/snippets/Translate/TranslateClientTest.php @@ -33,8 +33,8 @@ class TranslateClientTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->client = new \TranslateClientStub; - $this->client->setConnection($this->connection->reveal()); + $this->client = \Google\Cloud\Dev\stub(TranslateClient::class); + $this->client->___setProperty('connection', $this->connection->reveal()); } public function testClass() @@ -63,7 +63,7 @@ public function testTranslate() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('foobar', $res->output()); @@ -87,7 +87,7 @@ public function testTranslateBatch() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('foobar', $res->output()); @@ -113,7 +113,7 @@ public function testDetectLanguage() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('en', $res->output()); @@ -139,7 +139,7 @@ public function testDetectLanguageBatch() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('en', $res->output()); @@ -163,7 +163,7 @@ public function testLanguages() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('en', $res->output()); @@ -187,7 +187,7 @@ public function testLocalizedLanguages() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke(); $this->assertEquals('en', $res->output()); diff --git a/tests/snippets/Vision/VisionClientTest.php b/tests/snippets/Vision/VisionClientTest.php index 832c33591c4b..e24f00ee0b9f 100644 --- a/tests/snippets/Vision/VisionClientTest.php +++ b/tests/snippets/Vision/VisionClientTest.php @@ -35,8 +35,8 @@ class VisionClientTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->client = new \VisionClientStub; - $this->client->setConnection($this->connection->reveal()); + $this->client = \Google\Cloud\Dev\stub(VisionClient::class); + $this->client->___setProperty('connection', $this->connection->reveal()); } public function testClassWithServiceBuilder() @@ -115,7 +115,7 @@ public function testAnnotate() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('result'); @@ -145,7 +145,7 @@ public function testAnnotateBatch() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('result'); diff --git a/tests/snippets/bootstrap.php b/tests/snippets/bootstrap.php index 8fbd4cc1ed1c..298292891bbb 100644 --- a/tests/snippets/bootstrap.php +++ b/tests/snippets/bootstrap.php @@ -33,35 +33,3 @@ exit(1); } }); - -function stub($name, $extends) -{ - $tpl = 'class %s extends %s {use \Google\Cloud\Dev\SetStubConnectionTrait; }'; - - eval(sprintf($tpl, $name, $extends)); -} - -stub('AclStub', Google\Cloud\Storage\Acl::class); -stub('BigQueryClientStub', Google\Cloud\BigQuery\BigQueryClient::class); -stub('BucketStub', Google\Cloud\Storage\Bucket::class); -stub('DatastoreClientStub', Google\Cloud\Datastore\DatastoreClient::class); -stub('IamStub', Google\Cloud\Core\Iam\Iam::class); -stub('LoggerStub', Google\Cloud\Logging\Logger::class); -stub('LoggingClientStub', Google\Cloud\Logging\LoggingClient::class); -stub('MetricStub', Google\Cloud\Logging\Metric::class); -stub('LanguageClientStub', Google\Cloud\Language\LanguageClient::class); -stub('OperationStub', Google\Cloud\Datastore\Operation::class); -stub('PubSubClientStub', Google\Cloud\PubSub\PubSubClient::class); -stub('QueryResultsStub', Google\Cloud\BigQuery\QueryResults::class); -stub('SinkStub', Google\Cloud\Logging\Sink::class); -stub('SnapshotStub', Google\Cloud\PubSub\Snapshot::class); -stub('SpeechClientStub', Google\Cloud\Speech\SpeechClient::class); -stub('SpeechOperationStub', Google\Cloud\Speech\Operation::class); -stub('StorageClientStub', Google\Cloud\Storage\StorageClient::class); -stub('StorageObjectStub', Google\Cloud\Storage\StorageObject::class); -stub('SubscriptionStub', Google\Cloud\PubSub\Subscription::class); -stub('TableStub', Google\Cloud\BigQuery\Table::class); -stub('TopicStub', Google\Cloud\PubSub\Topic::class); -stub('TraceClientStub', Google\Cloud\Trace\TraceClient::class); -stub('TranslateClientStub', Google\Cloud\Translate\TranslateClient::class); -stub('VisionClientStub', Google\Cloud\Vision\VisionClient::class); diff --git a/tests/system/Spanner/AdminTest.php b/tests/system/Spanner/AdminTest.php new file mode 100644 index 000000000000..545deb89989a --- /dev/null +++ b/tests/system/Spanner/AdminTest.php @@ -0,0 +1,102 @@ +instances(); + $instance = array_filter(iterator_to_array($instances), function ($instance) { + return $instance->name() === self::INSTANCE_NAME; + }); + + $this->assertInstanceOf(Instance::class, $instance[0]); + + $instance = self::$instance; + $this->assertTrue($instance->exists()); + $this->assertEquals($instance->name(), $this->parseName($instance->info()['name'])); + $this->assertEquals($instance->name(), $this->parseName($instance->reload()['name'])); + + $this->assertEquals(Instance::STATE_READY, $instance->state()); + + $displayName = uniqid(self::TESTING_PREFIX); + $op = $instance->update([ + 'displayName' => $displayName + ]); + + $this->assertInstanceOf(LongRunningOperation::class, $op); + $op->pollUntilComplete(); + + $instance = $client->instance(self::INSTANCE_NAME); + $this->assertEquals($displayName, $instance->info()['displayName']); + } + + public function testDatabase() + { + $instance = self::$instance; + + $dbName = uniqid(self::TESTING_PREFIX); + $op = $instance->createDatabase($dbName); + + $this->assertInstanceOf(LongRunningOperation::class, $op); + $db = $op->pollUntilComplete(); + $this->assertInstanceOf(Database::class, $db); + + self::$deletionQueue[] = function() use ($db) { $db->drop(); }; + + $databases = $instance->databases(); + $database = array_filter(iterator_to_array($databases), function ($db) use ($dbName) { + return $db->name() === $dbName; + }); + + $this->assertInstanceOf(Database::class, current($database)); + + $this->assertTrue($db->exists()); + + $stmt = "CREATE TABLE Ids (\n" . + " id INT64 NOT NULL,\n" . + ") PRIMARY KEY(id)"; + + $op = $db->updateDdl($stmt); + $op->pollUntilComplete(); + + $this->assertEquals($db->ddl()[0], $stmt); + } + + private function parseName($name) + { + return InstanceAdminClient::parseInstanceFromInstanceName($name); + } + + private function parseDbName($name) + { + return DatabaseAdminClient::parseDatabaseFromDatabaseName($name); + } +} diff --git a/tests/system/Spanner/ConfigurationTest.php b/tests/system/Spanner/ConfigurationTest.php new file mode 100644 index 000000000000..94ded41522f1 --- /dev/null +++ b/tests/system/Spanner/ConfigurationTest.php @@ -0,0 +1,53 @@ +configurations(); + + $this->assertContainsOnly(Configuration::class, $configurations); + + $res = iterator_to_array($configurations); + $firstConfigName = $res[0]->name(); + + $config = $client->configuration($firstConfigName); + + $this->assertInstanceOf(Configuration::class, $config); + $this->assertEquals($firstConfigName, $config->name()); + + $this->assertTrue($config->exists()); + $this->assertEquals($config->name(), $this->parseName($config->info()['name'])); + $this->assertEquals($config->name(), $this->parseName($config->reload()['name'])); + } + + private function parseName($name) + { + return InstanceAdminClient::parseInstanceConfigFromInstanceConfigName($name); + } +} diff --git a/tests/system/Spanner/OperationsTest.php b/tests/system/Spanner/OperationsTest.php new file mode 100644 index 000000000000..fcfb952858f2 --- /dev/null +++ b/tests/system/Spanner/OperationsTest.php @@ -0,0 +1,142 @@ +insert(self::TEST_TABLE_NAME, [ + 'id' => $this->id, + 'name' => 'Bob', + 'birthday' => self::$client->date(new \DateTime('2000-01-01')) + ]); + + $this->assertInstanceOf(Timestamp::class, $res); + } + + /** + * @depends testInsert + */ + public function testExecute() + { + $db = self::$database; + + $row = $this->getRow(); + $this->assertEquals($this->id, $row['id']); + } + + /** + * @depends testInsert + */ + public function testRead() + { + $db = self::$database; + + $keySet = self::$client->keySet([ + 'keys' => [$this->id] + ]); + $columns = ['id', 'name']; + + $res = $db->read(self::TEST_TABLE_NAME, $keySet, $columns); + $row = $res->firstRow(); + $this->assertEquals($this->id, $row['id']); + } + + /** + * @depends testInsert + */ + public function testUpdate() + { + $db = self::$database; + $row = $this->getRow(); + $row['name'] = 'Doug'; + + $db->update('Users', $row); + + $row = $this->getRow(); + $this->assertEquals('Doug', $row['name']); + } + + /** + * @depends testInsert + */ + public function testInsertOrUpdate() + { + $db = self::$database; + $db->insertOrUpdate('Users', [ + 'id' => $this->id, + 'name' => 'Dave', + 'birthday' => new Date(new \DateTime('1990-01-01')) + ]); + + $row = $this->getRow(); + $this->assertEquals('Dave', $row['name']); + } + + /** + * @depends testInsert + */ + public function testReplace() + { + $db = self::$database; + $db->replace('Users', [ + 'id' => $this->id, + 'name' => 'John', + 'birthday' => new Date(new \DateTime('1990-01-01')) + ]); + + $row = $this->getRow(); + $this->assertEquals('John', $row['name']); + } + + /** + * @depends testInsert + */ + public function testDelete() + { + $db = self::$database; + $keySet = self::$client->keySet([ + 'keys' => [$this->id] + ]); + + $db->delete(self::TEST_TABLE_NAME, $keySet); + $this->assertNull($this->getRow()); + } + + private function getRow() + { + $db = self::$database; + $res = $db->execute('SELECT * FROM '. self::TEST_TABLE_NAME .' WHERE id=@id', [ + 'parameters' => [ + 'id' => $this->id + ] + ]); + + return $res->firstRow(); + } +} diff --git a/tests/system/Spanner/SnapshotTest.php b/tests/system/Spanner/SnapshotTest.php new file mode 100644 index 000000000000..d6f6e78cf784 --- /dev/null +++ b/tests/system/Spanner/SnapshotTest.php @@ -0,0 +1,57 @@ +id = rand(1,99999); + } + + public function testSnapshot() + { + $db = self::$database; + + $db->insert('Users', [ + 'id' => $this->id, + 'name' => 'John', + 'birthday' => new Date(new \DateTime('1990-01-01')) + ]); + + $snapshot = $db->snapshot(); + $row = $this->getRow($snapshot); + $this->assertEquals('John', $row['name']); + } + + private function getRow($client) + { + return $client->execute('SELECT * FROM Users WHERE id=@id', [ + 'parameters' => [ + 'id' => $this->id + ] + ])->firstRow(); + } +} diff --git a/tests/system/Spanner/SpannerTestCase.php b/tests/system/Spanner/SpannerTestCase.php new file mode 100644 index 000000000000..264a5542a6dd --- /dev/null +++ b/tests/system/Spanner/SpannerTestCase.php @@ -0,0 +1,80 @@ + $keyFilePath + ]); + + self::$instance = self::$client->instance(self::INSTANCE_NAME); + + $dbName = uniqid(self::TESTING_PREFIX); + $op = self::$instance->createDatabase($dbName); + $op->pollUntilComplete(); + $db = self::$client->connect(self::INSTANCE_NAME, $dbName); + + self::$deletionQueue[] = function() use ($db) { $db->drop(); }; + + $op = $db->updateDdl( + 'CREATE TABLE '. self::TEST_TABLE_NAME .' ( + id INT64 NOT NULL, + name STRING(MAX) NOT NULL, + birthday DATE NOT NULL + ) PRIMARY KEY (id)' + ); + $op->pollUntilComplete(); + + self::$database = $db; + } + + public static function tearDownFixtures() + { + $backoff = new ExponentialBackoff(8); + + foreach (self::$deletionQueue as $item) { + $backoff->execute($item); + } + } +} diff --git a/tests/system/Spanner/TransactionTest.php b/tests/system/Spanner/TransactionTest.php new file mode 100644 index 000000000000..1ce4f5825a81 --- /dev/null +++ b/tests/system/Spanner/TransactionTest.php @@ -0,0 +1,46 @@ +runTransaction(function ($t) { + $id = rand(1,346464); + $t->insert(self::TEST_TABLE_NAME, [ + 'id' => $id, + 'name' => uniqid(self::TESTING_PREFIX), + 'birthday' => new Date(new \DateTime) + ]); + + $t->commit(); + }); + + $db->runTransaction(function ($t) { + $t->rollback(); + }); + } +} diff --git a/tests/system/bootstrap.php b/tests/system/bootstrap.php index ca21ed09e3e6..f9e2b36a997c 100644 --- a/tests/system/bootstrap.php +++ b/tests/system/bootstrap.php @@ -2,11 +2,12 @@ require __DIR__ . '/../../vendor/autoload.php'; -use Google\Cloud\Tests\System\PubSub\PubSubTestCase; +use Google\Cloud\Tests\System\BigQuery\BigQueryTestCase; use Google\Cloud\Tests\System\Datastore\DatastoreTestCase; -use Google\Cloud\Tests\System\Storage\StorageTestCase; use Google\Cloud\Tests\System\Logging\LoggingTestCase; -use Google\Cloud\Tests\System\BigQuery\BigQueryTestCase; +use Google\Cloud\Tests\System\PubSub\PubSubTestCase; +use Google\Cloud\Tests\System\Spanner\SpannerTestCase; +use Google\Cloud\Tests\System\Storage\StorageTestCase; if (!getenv('GOOGLE_CLOUD_PHP_TESTS_KEY_PATH')) { throw new \Exception( @@ -20,4 +21,5 @@ StorageTestCase::tearDownFixtures(); LoggingTestCase::tearDownFixtures(); BigQueryTestCase::tearDownFixtures(); + SpannerTestCase::tearDownFixtures(); }); diff --git a/tests/unit/Core/ArrayTraitTest.php b/tests/unit/Core/ArrayTraitTest.php index 362b6e124923..2577051fd200 100644 --- a/tests/unit/Core/ArrayTraitTest.php +++ b/tests/unit/Core/ArrayTraitTest.php @@ -83,6 +83,26 @@ public function testIsAssocFalse() $this->assertFalse($actual); } + + public function testArrayFilterRemoveNull() + { + $input = [ + 'null' => null, + 'false' => false, + 'zero' => 0, + 'float' => 0.0, + 'empty' => '', + 'array' => [], + ]; + + $res = $this->implementation->call('arrayFilterRemoveNull', [$input]); + $this->assertFalse(array_key_exists('null', $res)); + $this->assertTrue(array_key_exists('false', $res)); + $this->assertTrue(array_key_exists('zero', $res)); + $this->assertTrue(array_key_exists('float', $res)); + $this->assertTrue(array_key_exists('empty', $res)); + $this->assertTrue(array_key_exists('array', $res)); + } } class ArrayTraitStub diff --git a/tests/unit/Core/PhpArrayTest.php b/tests/unit/Core/PhpArrayTest.php index 7a207cc2d605..1bb285b5190e 100644 --- a/tests/unit/Core/PhpArrayTest.php +++ b/tests/unit/Core/PhpArrayTest.php @@ -28,7 +28,7 @@ class PhpArrayTest extends \PHPUnit_Framework_TestCase { private function getCodec($customFilters = []) { - return new PhpArray($customFilters); + return new PhpArray(['customFilters' => $customFilters]); } /** diff --git a/tests/unit/Datastore/OperationTest.php b/tests/unit/Datastore/OperationTest.php index fee62dd72d4c..0df35468774d 100644 --- a/tests/unit/Datastore/OperationTest.php +++ b/tests/unit/Datastore/OperationTest.php @@ -893,6 +893,11 @@ public function testInvalidBatchType() class OperationStub extends Operation { + // public function runQuery(QueryInterface $q, array $args = []) + // { + // echo 'test'; + // exit; + // } public function setConnection($connection) { $this->connection = $connection; diff --git a/tests/unit/Spanner/BytesTest.php b/tests/unit/Spanner/BytesTest.php new file mode 100644 index 000000000000..d224738f96e2 --- /dev/null +++ b/tests/unit/Spanner/BytesTest.php @@ -0,0 +1,52 @@ +content); + $this->assertEquals($this->content, $bytes->get()); + } + + public function testFormatAsString() + { + $bytes = new Bytes($this->content); + $this->assertEquals(base64_encode($this->content), $bytes->formatAsString()); + } + + public function testCast() + { + $bytes = new Bytes($this->content); + $this->assertEquals(base64_encode($this->content), (string) $bytes); + } + + public function testType() + { + $bytes = new Bytes($this->content); + $this->assertTrue(is_integer($bytes->type())); + } +} diff --git a/tests/unit/Spanner/ConfigurationTest.php b/tests/unit/Spanner/ConfigurationTest.php new file mode 100644 index 000000000000..9cdceb8c336d --- /dev/null +++ b/tests/unit/Spanner/ConfigurationTest.php @@ -0,0 +1,116 @@ +connection = $this->prophesize(ConnectionInterface::class); + $this->configuration = \Google\Cloud\Dev\stub(Configuration::class, [ + $this->connection->reveal(), + self::PROJECT_ID, + self::NAME + ]); + } + + public function testName() + { + $this->assertEquals(self::NAME, $this->configuration->name()); + } + + public function testInfo() + { + $this->connection->getConfig(Argument::any())->shouldNotBeCalled(); + $this->configuration->___setProperty('connection', $this->connection->reveal()); + + $info = ['foo' => 'bar']; + $config = \Google\Cloud\Dev\stub(Configuration::class, [ + $this->connection->reveal(), + self::PROJECT_ID, + self::NAME, + $info + ]); + + $this->assertEquals($info, $config->info()); + } + + public function testInfoWithReload() + { + $info = ['foo' => 'bar']; + + $this->connection->getConfig([ + 'name' => InstanceAdminClient::formatInstanceConfigName(self::PROJECT_ID, self::NAME), + 'projectId' => self::PROJECT_ID + ])->shouldBeCalled()->willReturn($info); + + $this->configuration->___setProperty('connection', $this->connection->reveal()); + + $this->assertEquals($info, $this->configuration->info()); + } + + public function testExists() + { + $this->connection->getConfig(Argument::any())->willReturn([]); + $this->configuration->___setProperty('connection', $this->connection->reveal()); + + $this->assertTrue($this->configuration->exists()); + } + + public function testExistsDoesntExist() + { + $this->connection->getConfig(Argument::any())->willThrow(new NotFoundException('', 404)); + $this->configuration->___setProperty('connection', $this->connection->reveal()); + + $this->assertFalse($this->configuration->exists()); + } + + public function testReload() + { + $info = ['foo' => 'bar']; + + $this->connection->getConfig([ + 'name' => InstanceAdminClient::formatInstanceConfigName(self::PROJECT_ID, self::NAME), + 'projectId' => self::PROJECT_ID + ])->shouldBeCalledTimes(1)->willReturn($info); + + $this->configuration->___setProperty('connection', $this->connection->reveal()); + + $info = $this->configuration->reload(); + + $info2 = $this->configuration->info(); + + $this->assertEquals($info, $info2); + } +} diff --git a/tests/unit/Spanner/Connection/IamDatabaseTest.php b/tests/unit/Spanner/Connection/IamDatabaseTest.php new file mode 100644 index 000000000000..fafbb2f93eaf --- /dev/null +++ b/tests/unit/Spanner/Connection/IamDatabaseTest.php @@ -0,0 +1,66 @@ +connection = $this->prophesize(ConnectionInterface::class); + + $this->iam = \Google\Cloud\Dev\stub(IamDatabase::class, [$this->connection->reveal()]); + } + + /** + * @dataProvider methodProvider + */ + public function testMethods($methodName, $proxyName, $args) + { + $this->connection->$proxyName($args) + ->shouldBeCalled() + ->willReturn($args); + + $this->iam->___setProperty('connection', $this->connection->reveal()); + + $res = $this->iam->$methodName($args); + $this->assertEquals($args, $res); + } + + public function methodProvider() + { + $args = ['foo' => 'bar']; + + return [ + ['getPolicy', 'getDatabaseIamPolicy', $args], + ['setPolicy', 'setDatabaseIamPolicy', $args], + ['testPermissions', 'testDatabaseIamPermissions', $args] + ]; + } +} diff --git a/tests/unit/Spanner/Connection/IamInstanceTest.php b/tests/unit/Spanner/Connection/IamInstanceTest.php new file mode 100644 index 000000000000..426a4a125245 --- /dev/null +++ b/tests/unit/Spanner/Connection/IamInstanceTest.php @@ -0,0 +1,66 @@ +connection = $this->prophesize(ConnectionInterface::class); + + $this->iam = \Google\Cloud\Dev\stub(IamInstance::class, [$this->connection->reveal()]); + } + + /** + * @dataProvider methodProvider + */ + public function testMethods($methodName, $proxyName, $args) + { + $this->connection->$proxyName($args) + ->shouldBeCalled() + ->willReturn($args); + + $this->iam->___setProperty('connection', $this->connection->reveal()); + + $res = $this->iam->$methodName($args); + $this->assertEquals($args, $res); + } + + public function methodProvider() + { + $args = ['foo' => 'bar']; + + return [ + ['getPolicy', 'getInstanceIamPolicy', $args], + ['setPolicy', 'setInstanceIamPolicy', $args], + ['testPermissions', 'testInstanceIamPermissions', $args] + ]; + } +} diff --git a/tests/unit/Spanner/Connection/LongRunningConnectionTest.php b/tests/unit/Spanner/Connection/LongRunningConnectionTest.php new file mode 100644 index 000000000000..f1ce2489d281 --- /dev/null +++ b/tests/unit/Spanner/Connection/LongRunningConnectionTest.php @@ -0,0 +1,66 @@ +connection = $this->prophesize(ConnectionInterface::class); + $this->lro = \Google\Cloud\Dev\stub(LongRunningConnection::class, [ + $this->connection->reveal() + ]); + } + + /** + * @dataProvider methodProvider + */ + public function testMethods($methodName, $proxyName, $args) + { + $this->connection->$proxyName($args) + ->shouldBeCalled() + ->willReturn($args); + + $this->lro->___setProperty('connection', $this->connection->reveal()); + + $res = $this->lro->$methodName($args); + $this->assertEquals($args, $res); + } + + public function methodProvider() + { + $args = ['foo' => 'bar']; + + return [ + ['get', 'getOperation', $args], + ['cancel', 'cancelOperation', $args], + ['delete', 'deleteOperation', $args], + ['operations', 'listOperations', $args] + ]; + } +} diff --git a/tests/unit/Spanner/DatabaseTest.php b/tests/unit/Spanner/DatabaseTest.php new file mode 100644 index 000000000000..5216a69fc6f0 --- /dev/null +++ b/tests/unit/Spanner/DatabaseTest.php @@ -0,0 +1,620 @@ +connection = $this->prophesize(ConnectionInterface::class); + $this->instance = $this->prophesize(Instance::class); + $this->sessionPool = $this->prophesize(SessionPoolInterface::class); + $this->lro = $this->prophesize(LongRunningConnectionInterface::class); + $this->lroCallables = []; + + $this->sessionPool->session(self::INSTANCE, self::DATABASE, Argument::any()) + ->willReturn(new Session( + $this->connection->reveal(), + self::PROJECT, + self::INSTANCE, + self::DATABASE, + self::SESSION + )); + + $this->instance->name()->willReturn(self::INSTANCE); + + $args = [ + $this->connection->reveal(), + $this->instance->reveal(), + $this->sessionPool->reveal(), + $this->lro->reveal(), + $this->lroCallables, + self::PROJECT, + self::DATABASE, + ]; + + $props = [ + 'connection', 'operation' + ]; + + $this->database = \Google\Cloud\Dev\stub(Database::class, $args, $props); + } + + /** + * @group spanneradmin + */ + public function testExists() + { + $this->connection->getDatabaseDDL(Argument::any()) + ->shouldBeCalled() + ->willReturn([]); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $this->assertTrue($this->database->exists()); + } + + /** + * @group spanneradmin + */ + public function testExistsNotFound() + { + $this->connection->getDatabaseDDL(Argument::any()) + ->shouldBeCalled() + ->willThrow(new NotFoundException('', 404)); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $this->assertFalse($this->database->exists()); + } + + /** + * @group spanneradmin + */ + public function testUpdateDdl() + { + $statement = 'foo'; + $this->connection->updateDatabase([ + 'name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT, self::INSTANCE, self::DATABASE), + 'statements' => [$statement] + ]); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $this->database->updateDdl($statement); + } + + /** + * @group spanneradmin + */ + public function testUpdateDdlBatch() + { + $statements = ['foo', 'bar']; + $this->connection->updateDatabase([ + 'name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT, self::INSTANCE, self::DATABASE), + 'statements' => $statements + ]); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $this->database->updateDdl($statements); + } + + /** + * @group spanneradmin + */ + public function testUpdateWithSingleStatement() + { + $statement = 'foo'; + $this->connection->updateDatabase([ + 'name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT, self::INSTANCE, self::DATABASE), + 'statements' => ['foo'] + ])->shouldBeCalled()->willReturn(['name' => 'operations/foo']); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $res = $this->database->updateDdl($statement); + $this->assertInstanceOf(LongRunningOperation::class, $res); + } + + /** + * @group spanneradmin + */ + public function testDrop() + { + $this->connection->dropDatabase([ + 'name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT, self::INSTANCE, self::DATABASE) + ])->shouldBeCalled(); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $this->database->drop(); + } + + /** + * @group spanneradmin + */ + public function testDdl() + { + $ddl = ['create table users', 'create table posts']; + $this->connection->getDatabaseDDL([ + 'name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT, self::INSTANCE, self::DATABASE) + ])->willReturn(['statements' => $ddl]); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $this->assertEquals($ddl, $this->database->ddl()); + } + + /** + * @group spanneradmin + */ + public function testDdlNoResult() + { + $this->connection->getDatabaseDDL([ + 'name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT, self::INSTANCE, self::DATABASE) + ])->willReturn([]); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $this->assertEquals([], $this->database->ddl()); + } + + /** + * @group spanneradmin + */ + public function testIam() + { + $this->assertInstanceOf(Iam::class, $this->database->iam()); + } + + public function testSnapshot() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn(['id' => self::TRANSACTION]); + + $this->refreshOperation(); + + $res = $this->database->snapshot(); + $this->assertInstanceOf(Snapshot::class, $res); + } + + /** + * @expectedException BadMethodCallException + */ + public function testSnapshotMinReadTimestamp() + { + $this->database->snapshot(['minReadTimestamp' => 'foo']); + } + + /** + * @expectedException BadMethodCallException + */ + public function testSnapshotMaxStaleness() + { + $this->database->snapshot(['maxStaleness' => 'foo']); + } + + public function testRunTransaction() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn(['id' => self::TRANSACTION]); + + $this->refreshOperation(); + + $hasTransaction = false; + + $this->database->runTransaction(function (Transaction $t) use (&$hasTransaction) { + $hasTransaction = true; + }); + + $this->assertTrue($hasTransaction); + } + + public function testRunTransactionRetry() + { + $abort = new AbortedException('foo', 409, null, [ + [ + 'retryDelay' => [ + 'seconds' => 1, + 'nanos' => 0 + ] + ] + ]); + + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalledTimes(3) + ->willReturn(['id' => self::TRANSACTION]); + + $it = 0; + $this->connection->commit(Argument::any()) + ->shouldBeCalledTimes(3) + ->will(function() use (&$it, $abort) { + $it++; + if ($it <= 2) { + throw $abort; + } + + return ['commitTimestamp' => TransactionTest::TIMESTAMP]; + }); + + $this->refreshOperation(); + + $this->database->runTransaction(function($t){$t->commit();}); + } + + /** + * @expectedException Google\Cloud\Core\Exception\AbortedException + */ + public function testRunTransactionAborted() + { + $abort = new AbortedException('foo', 409, null, [ + [ + 'retryDelay' => [ + 'seconds' => 1, + 'nanos' => 0 + ] + ] + ]); + + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn(['id' => self::TRANSACTION]); + + $it = 0; + $this->connection->commit(Argument::any()) + ->shouldBeCalled() + ->will(function() use (&$it, $abort) { + $it++; + if ($it <= 8) { + throw $abort; + } + + return ['commitTimestamp' => TransactionTest::TIMESTAMP]; + }); + + $this->refreshOperation(); + + $this->database->runTransaction(function($t){$t->commit();}); + } + + public function testTransaction() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn(['id' => self::TRANSACTION]); + + $this->refreshOperation(); + + $t = $this->database->transaction(); + $this->assertInstanceOf(Transaction::class, $t); + } + + public function testInsert() + { + $table = 'foo'; + $row = ['col' => 'val']; + + $this->connection->commit(Argument::that(function ($arg) use ($table, $row) { + if ($arg['mutations'][0][OPERATION::OP_INSERT]['table'] !== $table) return false; + if ($arg['mutations'][0][OPERATION::OP_INSERT]['columns'][0] !== array_keys($row)[0]) return false; + if ($arg['mutations'][0][OPERATION::OP_INSERT]['values'][0] !== current($row)) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->commitResponse()); + + $this->refreshOperation(); + + $res = $this->database->insert($table, $row); + $this->assertInstanceOf(Timestamp::class, $res); + $this->assertTimestampIsCorrect($res); + } + + public function testInsertBatch() + { + $table = 'foo'; + $row = ['col' => 'val']; + + $this->connection->commit(Argument::that(function ($arg) use ($table, $row) { + if ($arg['mutations'][0][OPERATION::OP_INSERT]['table'] !== $table) return false; + if ($arg['mutations'][0][OPERATION::OP_INSERT]['columns'][0] !== array_keys($row)[0]) return false; + if ($arg['mutations'][0][OPERATION::OP_INSERT]['values'][0] !== current($row)) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->commitResponse()); + + $this->refreshOperation(); + + $res = $this->database->insertBatch($table, [$row]); + $this->assertInstanceOf(Timestamp::class, $res); + $this->assertTimestampIsCorrect($res); + } + + public function testUpdate() + { + $table = 'foo'; + $row = ['col' => 'val']; + + $this->connection->commit(Argument::that(function ($arg) use ($table, $row) { + if ($arg['mutations'][0][Operation::OP_UPDATE]['table'] !== $table) return false; + if ($arg['mutations'][0][Operation::OP_UPDATE]['columns'][0] !== array_keys($row)[0]) return false; + if ($arg['mutations'][0][Operation::OP_UPDATE]['values'][0] !== current($row)) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->commitResponse()); + + $this->refreshOperation(); + + $res = $this->database->update($table, $row); + $this->assertInstanceOf(Timestamp::class, $res); + $this->assertTimestampIsCorrect($res); + } + + public function testUpdateBatch() + { + $table = 'foo'; + $row = ['col' => 'val']; + + $this->connection->commit(Argument::that(function ($arg) use ($table, $row) { + if ($arg['mutations'][0][Operation::OP_UPDATE]['table'] !== $table) return false; + if ($arg['mutations'][0][Operation::OP_UPDATE]['columns'][0] !== array_keys($row)[0]) return false; + if ($arg['mutations'][0][Operation::OP_UPDATE]['values'][0] !== current($row)) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->commitResponse()); + + $this->refreshOperation(); + + $res = $this->database->updateBatch($table, [$row]); + $this->assertInstanceOf(Timestamp::class, $res); + $this->assertTimestampIsCorrect($res); + } + + public function testInsertOrUpdate() + { + $table = 'foo'; + $row = ['col' => 'val']; + + $this->connection->commit(Argument::that(function ($arg) use ($table, $row) { + if ($arg['mutations'][0][Operation::OP_INSERT_OR_UPDATE]['table'] !== $table) return false; + if ($arg['mutations'][0][Operation::OP_INSERT_OR_UPDATE]['columns'][0] !== array_keys($row)[0]) return false; + if ($arg['mutations'][0][Operation::OP_INSERT_OR_UPDATE]['values'][0] !== current($row)) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->commitResponse()); + + $this->refreshOperation(); + + $res = $this->database->insertOrUpdate($table, $row); + $this->assertInstanceOf(Timestamp::class, $res); + $this->assertTimestampIsCorrect($res); + } + + public function testInsertOrUpdateBatch() + { + $table = 'foo'; + $row = ['col' => 'val']; + + $this->connection->commit(Argument::that(function ($arg) use ($table, $row) { + if ($arg['mutations'][0][Operation::OP_INSERT_OR_UPDATE]['table'] !== $table) return false; + if ($arg['mutations'][0][Operation::OP_INSERT_OR_UPDATE]['columns'][0] !== array_keys($row)[0]) return false; + if ($arg['mutations'][0][Operation::OP_INSERT_OR_UPDATE]['values'][0] !== current($row)) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->commitResponse()); + + $this->refreshOperation(); + + $res = $this->database->insertOrUpdateBatch($table, [$row]); + $this->assertInstanceOf(Timestamp::class, $res); + $this->assertTimestampIsCorrect($res); + } + + public function testReplace() + { + $table = 'foo'; + $row = ['col' => 'val']; + + $this->connection->commit(Argument::that(function ($arg) use ($table, $row) { + if ($arg['mutations'][0][Operation::OP_REPLACE]['table'] !== $table) return false; + if ($arg['mutations'][0][Operation::OP_REPLACE]['columns'][0] !== array_keys($row)[0]) return false; + if ($arg['mutations'][0][Operation::OP_REPLACE]['values'][0] !== current($row)) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->commitResponse()); + + $this->refreshOperation(); + + $res = $this->database->replace($table, $row); + $this->assertInstanceOf(Timestamp::class, $res); + $this->assertTimestampIsCorrect($res); + } + + public function testReplaceBatch() + { + $table = 'foo'; + $row = ['col' => 'val']; + + $this->connection->commit(Argument::that(function ($arg) use ($table, $row) { + if ($arg['mutations'][0][Operation::OP_REPLACE]['table'] !== $table) return false; + if ($arg['mutations'][0][Operation::OP_REPLACE]['columns'][0] !== array_keys($row)[0]) return false; + if ($arg['mutations'][0][Operation::OP_REPLACE]['values'][0] !== current($row)) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->commitResponse()); + + $this->refreshOperation(); + + $res = $this->database->replaceBatch($table, [$row]); + $this->assertInstanceOf(Timestamp::class, $res); + $this->assertTimestampIsCorrect($res); + } + + public function testDelete() + { + $table = 'foo'; + $keys = [10, 'bar']; + + $this->connection->commit(Argument::that(function ($arg) use ($table, $keys) { + if ($arg['mutations'][0][Operation::OP_DELETE]['table'] !== $table) return false; + if ($arg['mutations'][0][Operation::OP_DELETE]['keySet']['keys'][0] !== (string) $keys[0]) return false; + if ($arg['mutations'][0][Operation::OP_DELETE]['keySet']['keys'][1] !== $keys[1]) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->commitResponse()); + + $this->refreshOperation(); + + $res = $this->database->delete($table, new KeySet(['keys' => $keys])); + $this->assertInstanceOf(Timestamp::class, $res); + $this->assertTimestampIsCorrect($res); + } + + public function testExecute() + { + $sql = 'SELECT * FROM Table'; + + $this->connection->executeSql(Argument::that(function ($arg) use ($sql) { + if ($arg['sql'] !== $sql) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'ID', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'rows' => [ + [ + '10' + ] + ] + ]); + + $this->refreshOperation(); + + $res = $this->database->execute($sql); + $this->assertInstanceOf(Result::class, $res); + $this->assertEquals(10, $res->rows()[0]['ID']); + } + + public function testRead() + { + $table = 'Table'; + $opts = ['foo' => 'bar']; + + $this->connection->read(Argument::that(function ($arg) use ($table, $opts) { + if ($arg['table'] !== $table) return false; + if ($arg['keySet']['all'] !== true) return false; + if ($arg['columns'] !== ['ID']) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'ID', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'rows' => [ + [ + '10' + ] + ] + ]); + + $this->refreshOperation(); + + $res = $this->database->read($table, new KeySet(['all' => true]), ['ID']); + $this->assertInstanceOf(Result::class, $res); + $this->assertEquals(10, $res->rows()[0]['ID']); + } + + // ******* + // Helpers + + private function refreshOperation() + { + $operation = new Operation($this->connection->reveal(), false); + $this->database->___setProperty('operation', $operation); + } + + private function commitResponse() + { + return ['commitTimestamp' => '2017-01-09T18:05:22.534799Z']; + } + + private function assertTimestampIsCorrect($res) + { + $ts = new \DateTimeImmutable($this->commitResponse()['commitTimestamp']); + + $this->assertEquals($ts->format('Y-m-d\TH:i:s\Z'), $res->get()->format('Y-m-d\TH:i:s\Z')); + } +} diff --git a/tests/unit/Spanner/DateTest.php b/tests/unit/Spanner/DateTest.php new file mode 100644 index 000000000000..1c25c6bc3245 --- /dev/null +++ b/tests/unit/Spanner/DateTest.php @@ -0,0 +1,55 @@ +dt = new \DateTime('1989-10-11'); + $this->date = new Date($this->dt); + } + + public function testGet() + { + $this->assertEquals($this->dt, $this->date->get()); + } + + public function testFormatAsString() + { + $this->assertEquals($this->dt->format(Date::FORMAT), $this->date->formatAsString()); + } + + public function testCast() + { + $this->assertEquals($this->dt->format(Date::FORMAT), (string)$this->date); + } + + public function testType() + { + $this->assertTrue(is_integer($this->date->type())); + } +} diff --git a/tests/unit/Spanner/DurationTest.php b/tests/unit/Spanner/DurationTest.php new file mode 100644 index 000000000000..37d2575e42f7 --- /dev/null +++ b/tests/unit/Spanner/DurationTest.php @@ -0,0 +1,65 @@ +duration = new Duration(self::SECONDS, self::NANOS); + } + + public function testGet() + { + $this->assertEquals([ + 'seconds' => self::SECONDS, + 'nanos' => self::NANOS + ], $this->duration->get()); + } + + public function testType() + { + $this->assertEquals(Duration::TYPE, $this->duration->type()); + } + + public function testFormatAsString() + { + $this->assertEquals( + json_encode($this->duration->get()), + $this->duration->formatAsString() + ); + } + + public function testTostring() + { + $this->assertEquals( + json_encode($this->duration->get()), + (string)$this->duration + ); + } +} diff --git a/tests/unit/Spanner/InstanceTest.php b/tests/unit/Spanner/InstanceTest.php new file mode 100644 index 000000000000..5612ce712733 --- /dev/null +++ b/tests/unit/Spanner/InstanceTest.php @@ -0,0 +1,318 @@ +connection = $this->prophesize(ConnectionInterface::class); + $this->instance = \Google\Cloud\Dev\stub(Instance::class, [ + $this->connection->reveal(), + $this->prophesize(SessionPoolInterface::class)->reveal(), + $this->prophesize(LongRunningConnectionInterface::class)->reveal(), + [], + self::PROJECT_ID, + self::NAME + ], [ + 'info', + 'connection' + ]); + } + + public function testName() + { + $this->assertEquals(self::NAME, $this->instance->name()); + } + + public function testInfo() + { + $this->connection->getInstance()->shouldNotBeCalled(); + + $this->instance->___setProperty('info', ['foo' => 'bar']); + $this->assertEquals('bar', $this->instance->info()['foo']); + } + + public function testInfoWithReload() + { + $instance = $this->getDefaultInstance(); + + $this->connection->getInstance(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($instance); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $info = $this->instance->info(); + $this->assertEquals('Instance Name', $info['displayName']); + + $this->assertEquals($info, $this->instance->info()); + } + + public function testExists() + { + $this->connection->getInstance(Argument::any())->shouldBeCalled()->willReturn([]); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $this->assertTrue($this->instance->exists()); + } + + public function testExistsNotFound() + { + $this->connection->getInstance(Argument::any()) + ->shouldBeCalled() + ->willThrow(new NotFoundException('foo', 404)); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $this->assertFalse($this->instance->exists()); + } + + public function testReload() + { + $instance = $this->getDefaultInstance(); + + $this->connection->getInstance(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($instance); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $info = $this->instance->reload(); + + $this->assertEquals('Instance Name', $info['displayName']); + } + + public function testState() + { + $instance = $this->getDefaultInstance(); + + $this->connection->getInstance(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($instance); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $this->assertEquals(Instance::STATE_READY, $this->instance->state()); + } + + public function testStateIsNull() + { + $this->connection->getInstance(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn([]); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $this->assertNull($this->instance->state()); + } + + public function testUpdate() + { + $instance = $this->getDefaultInstance(); + + $this->connection->getInstance(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($instance); + + $this->connection->updateInstance([ + 'name' => $instance['name'], + 'displayName' => $instance['displayName'], + 'nodeCount' => $instance['nodeCount'], + 'labels' => [], + ])->shouldBeCalled(); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $this->instance->update(); + } + + public function testUpdateWithExistingLabels() + { + $instance = $this->getDefaultInstance(); + $instance['labels'] = ['foo' => 'bar']; + + $this->connection->getInstance(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($instance); + + $this->connection->updateInstance([ + 'name' => $instance['name'], + 'displayName' => $instance['displayName'], + 'nodeCount' => $instance['nodeCount'], + 'labels' => $instance['labels'], + ])->shouldBeCalled(); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $this->instance->update(); + } + + public function testUpdateWithChanges() + { + $instance = $this->getDefaultInstance(); + + $changes = [ + 'labels' => [ + 'foo' => 'bar' + ], + 'nodeCount' => 900, + 'displayName' => 'New Name', + ]; + + $this->connection->getInstance(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($instance); + + $this->connection->updateInstance([ + 'name' => $instance['name'], + 'displayName' => $changes['displayName'], + 'nodeCount' => $changes['nodeCount'], + 'labels' => $changes['labels'], + ])->shouldBeCalled(); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $this->instance->update($changes); + } + + public function testDelete() + { + $this->connection->deleteInstance([ + 'name' => InstanceAdminClient::formatInstanceName(self::PROJECT_ID, self::NAME) + ])->shouldBeCalled(); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $this->instance->delete(); + } + + public function testCreateDatabase() + { + $extra = ['foo', 'bar']; + + $this->connection->createDatabase([ + 'instance' => InstanceAdminClient::formatInstanceName(self::PROJECT_ID, self::NAME), + 'createStatement' => 'CREATE DATABASE `test-database`', + 'extraStatements' => $extra + ]) + ->shouldBeCalled() + ->willReturn(['name' => 'operations/foo']); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $database = $this->instance->createDatabase('test-database', [ + 'statements' => $extra + ]); + + $this->assertInstanceOf(LongRunningOperation::class, $database); + } + + public function testDatabase() + { + $database = $this->instance->database('test-database'); + $this->assertInstanceOf(Database::class, $database); + $this->assertEquals('test-database', $database->name()); + } + + public function testDatabases() + { + $databases = [ + ['name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT_ID, self::NAME, 'database1')], + ['name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT_ID, self::NAME, 'database2')] + ]; + + $this->connection->listDatabases(Argument::any()) + ->shouldBeCalled() + ->willReturn(['databases' => $databases]); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $dbs = $this->instance->databases(); + + $this->assertInstanceOf(ItemIterator::class, $dbs); + + $dbs = iterator_to_array($dbs); + + $this->assertEquals(2, count($dbs)); + $this->assertEquals('database1', $dbs[0]->name()); + $this->assertEquals('database2', $dbs[1]->name()); + } + + public function testDatabasesPaged() + { + $databases = [ + ['name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT_ID, self::NAME, 'database1')], + ['name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT_ID, self::NAME, 'database2')] + ]; + + $iteration = 0; + $this->connection->listDatabases(Argument::any()) + ->shouldBeCalledTimes(2) + ->willReturn(['databases' => [$databases[0]], 'nextPageToken' => 'foo'], ['databases' => [$databases[1]]]); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $dbs = $this->instance->databases(); + + $this->assertInstanceOf(ItemIterator::class, $dbs); + + $dbs = iterator_to_array($dbs); + + $this->assertEquals(2, count($dbs)); + $this->assertEquals('database1', $dbs[0]->name()); + $this->assertEquals('database2', $dbs[1]->name()); + } + + public function testIam() + { + $this->assertInstanceOf(Iam::class, $this->instance->iam()); + } + + // ************** // + + private function getDefaultInstance() + { + return json_decode(file_get_contents(__DIR__ .'/../fixtures/spanner/instance.json'), true); + } +} diff --git a/tests/unit/Spanner/KeyRangeTest.php b/tests/unit/Spanner/KeyRangeTest.php new file mode 100644 index 000000000000..f3903ce6aa7d --- /dev/null +++ b/tests/unit/Spanner/KeyRangeTest.php @@ -0,0 +1,95 @@ +range = new KeyRange; + } + + public function testGetters() + { + $range = new KeyRange([ + 'startType' => KeyRange::TYPE_CLOSED, + 'start' => ['foo'], + 'endType' => KeyRange::TYPE_OPEN, + 'end' => ['bar'] + ]); + + $this->assertEquals(['foo'], $range->start()); + $this->assertEquals(['bar'], $range->end()); + $this->assertEquals(['start' => KeyRange::TYPE_CLOSED, 'end' => KeyRange::TYPE_OPEN], $range->types()); + } + + public function testSetStart() + { + $this->range->setStart(KeyRange::TYPE_OPEN, ['foo']); + $this->assertEquals(['foo'], $this->range->start()); + $this->assertEquals('startOpen', $this->range->types()['start']); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testSetStartInvalidType() + { + $this->range->setStart('foo', ['foo']); + } + + public function testSetEnd() + { + $this->range->setEnd(KeyRange::TYPE_OPEN, ['foo']); + $this->assertEquals(['foo'], $this->range->end()); + $this->assertEquals('endOpen', $this->range->types()['end']); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testSetEndInvalidType() + { + $this->range->setEnd('foo', ['foo']); + } + + public function testKeyRangeObject() + { + $this->range->setStart(KeyRange::TYPE_OPEN, ['foo']); + $this->range->setEnd(KeyRange::TYPE_CLOSED, ['bar']); + + $res = $this->range->keyRangeObject(); + + $this->assertEquals(['startOpen' => ['foo'], 'endClosed' => ['bar']], $res); + } + + /** + * @expectedException BadMethodCallException + */ + public function testKeyRangeObjectBadRange() + { + $this->range->keyRangeObject(); + } +} diff --git a/tests/unit/Spanner/KeySetTest.php b/tests/unit/Spanner/KeySetTest.php new file mode 100644 index 000000000000..75551a6dc946 --- /dev/null +++ b/tests/unit/Spanner/KeySetTest.php @@ -0,0 +1,119 @@ +prophesize(KeyRange::class); + $range->keyRangeObject()->willReturn('foo'); + + $set->addRange($range->reveal()); + + $this->assertEquals('foo', $set->keySetObject()['ranges'][0]); + } + + public function testSetRanges() + { + $set = new KeySet; + + $range1 = $this->prophesize(KeyRange::class); + $range1->keyRangeObject()->willReturn('foo'); + + $range2 = $this->prophesize(KeyRange::class); + $range2->keyRangeObject()->willReturn('bar'); + + $ranges = [ + $range1->reveal(), + $range2->reveal() + ]; + + $set->setRanges($ranges); + + $this->assertEquals('foo', $set->keySetObject()['ranges'][0]); + $this->assertEquals('bar', $set->keySetObject()['ranges'][1]); + } + + public function testAddKey() + { + $set = new KeySet; + + $key = 'key'; + + $set->addKey($key); + + $this->assertEquals($key, $set->keySetObject()['keys'][0]); + } + + public function testSetKeys() + { + $set = new KeySet; + + $keys = ['key1','key2']; + + $set->setKeys($keys); + + $this->assertEquals($keys, $set->keySetObject()['keys']); + } + + public function testSetMatchAll() + { + $set = new KeySet; + + $set->setMatchAll(true); + $this->assertTrue($set->keySetObject()['all']); + + $set->setMatchAll(false); + $this->assertFalse($set->keySetObject()['all']); + } + + public function testRanges() + { + $set = new KeySet; + $range = $this->prophesize(KeyRange::class)->reveal(); + + $set->addRange($range); + $this->assertEquals($range, $set->ranges()[0]); + } + + public function testKeys() + { + $set = new KeySet; + $key = 'foo'; + $set->addKey($key); + + $this->assertEquals($key, $set->keys()[0]); + } + + public function testMatchAll() + { + $set = new KeySet(); + $this->assertFalse($set->matchAll()); + + $set->setMatchAll(true); + $this->assertTrue($set->matchAll()); + } +} diff --git a/tests/unit/Spanner/OperationTest.php b/tests/unit/Spanner/OperationTest.php new file mode 100644 index 000000000000..298e8531632a --- /dev/null +++ b/tests/unit/Spanner/OperationTest.php @@ -0,0 +1,303 @@ +connection = $this->prophesize(ConnectionInterface::class); + + $this->operation = \Google\Cloud\Dev\stub(Operation::class, [ + $this->connection->reveal(), + false + ]); + + $session = $this->prophesize(Session::class); + $session->name()->willReturn(self::SESSION); + $this->session = $session->reveal(); + } + + public function testMutation() + { + $res = $this->operation->mutation(Operation::OP_INSERT, 'Posts', [ + 'foo' => 'bar' + ]); + + $this->assertEquals(Operation::OP_INSERT, array_keys($res)[0]); + $this->assertEquals('Posts', $res[Operation::OP_INSERT]['table']); + $this->assertEquals('foo', $res[Operation::OP_INSERT]['columns'][0]); + $this->assertEquals('bar', $res[Operation::OP_INSERT]['values'][0]); + } + + public function testDeleteMutation() + { + $keys = ['foo', 'bar']; + $range = new KeyRange([ + 'startType' => KeyRange::TYPE_CLOSED, + 'start' => ['foo'], + 'endType' => KeyRange::TYPE_OPEN, + 'end' => ['bar'] + ]); + + $keySet = new KeySet([ + 'keys' => $keys, + 'ranges' => [$range] + ]); + + $res = $this->operation->deleteMutation('Posts', $keySet); + + $this->assertEquals('Posts', $res['delete']['table']); + $this->assertEquals($keys, $res['delete']['keySet']['keys']); + $this->assertEquals($range->keyRangeObject(), $res['delete']['keySet']['ranges'][0]); + } + + public function testCommit() + { + $mutations = [ + $this->operation->mutation(Operation::OP_INSERT, 'Posts', [ + 'foo' => 'bar' + ]) + ]; + + $this->connection->commit(Argument::that(function ($arg) use ($mutations) { + if ($arg['mutations'] !== $mutations) return false; + if ($arg['transactionId'] !== 'foo') return false; + + return true; + }))->shouldBeCalled()->willReturn(['commitTimestamp' => self::TIMESTAMP]); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $res = $this->operation->commit($this->session, $mutations, [ + 'transactionId' => 'foo' + ]); + + $this->assertInstanceOf(Timestamp::class, $res); + } + + public function testCommitWithExistingTransaction() + { + $mutations = [ + $this->operation->mutation(Operation::OP_INSERT, 'Posts', [ + 'foo' => 'bar' + ]) + ]; + + $this->connection->commit(Argument::that(function ($arg) use ($mutations) { + if ($arg['mutations'] !== $mutations) return false; + if (isset($arg['singleUseTransaction'])) return false; + if ($arg['transactionId'] !== self::TRANSACTION) return false; + + return true; + }))->shouldBeCalled()->willReturn(['commitTimestamp' => self::TIMESTAMP]); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $res = $this->operation->commit($this->session, $mutations, [ + 'transactionId' => self::TRANSACTION + ]); + + $this->assertInstanceOf(Timestamp::class, $res); + } + + public function testRollback() + { + $this->connection->rollback(Argument::that(function ($arg) { + if ($arg['transactionId'] !== self::TRANSACTION) return false; + if ($arg['session'] !== self::SESSION) return false; + + return true; + }))->shouldBeCalled(); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $this->operation->rollback($this->session, self::TRANSACTION); + } + + public function testExecute() + { + $sql = 'SELECT * FROM Posts WHERE ID = @id'; + $params = ['id' => 10]; + + $this->connection->executeSql(Argument::that(function ($arg) use ($sql, $params) { + if ($arg['sql'] !== $sql) return false; + if ($arg['session'] !== self::SESSION) return false; + if ($arg['params'] !== ['id' => '10']) return false; + if ($arg['paramTypes']['id']['code'] !== ValueMapper::TYPE_INT64) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->executeAndReadResponse()); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $res = $this->operation->execute($this->session, $sql, [ + 'parameters' => $params + ]); + + $this->assertInstanceOf(Result::class, $res); + $this->assertEquals(10, $res->rows()[0]['ID']); + } + + public function testRead() + { + $this->connection->read(Argument::that(function ($arg) { + if ($arg['table'] !== 'Posts') return false; + if ($arg['session'] !== self::SESSION) return false; + if ($arg['keySet']['all'] !== true) return false; + if ($arg['columns'] !== ['foo']) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->executeAndReadResponse()); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $res = $this->operation->read($this->session, 'Posts', new KeySet(['all' => true]), ['foo']); + $this->assertInstanceOf(Result::class, $res); + $this->assertEquals(10, $res->rows()[0]['ID']); + } + + public function testReadWithTransaction() + { + $this->connection->read(Argument::that(function ($arg) { + if ($arg['table'] !== 'Posts') return false; + if ($arg['session'] !== self::SESSION) return false; + if ($arg['keySet']['all'] !== true) return false; + if ($arg['columns'] !== ['foo']) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->executeAndReadResponse([ + 'transaction' => ['id' => self::TRANSACTION] + ])); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $res = $this->operation->read($this->session, 'Posts', new KeySet(['all' => true]), ['foo'], [ + 'transactionContext' => SessionPoolInterface::CONTEXT_READWRITE + ]); + $this->assertInstanceOf(Transaction::class, $res->transaction()); + $this->assertEquals(self::TRANSACTION, $res->transaction()->id()); + } + + public function testReadWithSnapshot() + { + $this->connection->read(Argument::that(function ($arg) { + if ($arg['table'] !== 'Posts') return false; + if ($arg['session'] !== self::SESSION) return false; + if ($arg['keySet']['all'] !== true) return false; + if ($arg['columns'] !== ['foo']) return false; + + return true; + }))->shouldBeCalled()->willReturn($this->executeAndReadResponse([ + 'transaction' => ['id' => self::TRANSACTION] + ])); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $res = $this->operation->read($this->session, 'Posts', new KeySet(['all' => true]), ['foo'], [ + 'transactionContext' => SessionPoolInterface::CONTEXT_READ + ]); + $this->assertInstanceOf(Snapshot::class, $res->snapshot()); + $this->assertEquals(self::TRANSACTION, $res->snapshot()->id()); + } + + public function testTransaction() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn(['id' => self::TRANSACTION]); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $t = $this->operation->transaction($this->session); + $this->assertInstanceOf(Transaction::class, $t); + $this->assertEquals(self::TRANSACTION, $t->id()); + } + + public function testSnapshot() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn(['id' => self::TRANSACTION]); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snap = $this->operation->snapshot($this->session); + $this->assertInstanceOf(Snapshot::class, $snap); + $this->assertEquals(self::TRANSACTION, $snap->id()); + } + + public function testSnapshotWithTimestamp() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn(['id' => self::TRANSACTION, 'readTimestamp' => self::TIMESTAMP]); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snap = $this->operation->snapshot($this->session); + $this->assertInstanceOf(Snapshot::class, $snap); + $this->assertEquals(self::TRANSACTION, $snap->id()); + $this->assertInstanceOf(Timestamp::class, $snap->readTimestamp()); + } + + private function executeAndReadResponse(array $additionalMetadata = []) + { + return [ + 'metadata' => array_merge([ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'ID', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], $additionalMetadata), + 'rows' => [ + ['10'] + ] + ]; + } +} diff --git a/tests/unit/Spanner/ResultTest.php b/tests/unit/Spanner/ResultTest.php new file mode 100644 index 000000000000..98fa6d8ef6b5 --- /dev/null +++ b/tests/unit/Spanner/ResultTest.php @@ -0,0 +1,106 @@ + 'John'] + ]); + + $res = iterator_to_array($result); + $this->assertEquals(1, count($res)); + $this->assertEquals('John', $res[0]['name']); + } + + public function testMetadata() + { + $result = new Result(['metadata' => 'foo'], []); + $this->assertEquals('foo', $result->metadata()); + } + + public function testRows() + { + $rows = [ + ['name' => 'John'] + ]; + + $result = new Result([], $rows); + + $this->assertEquals($rows, $result->rows()); + } + + public function testFirstRow() + { + $rows = [ + ['name' => 'John'], + ['name' => 'Dave'] + ]; + + $result = new Result([], $rows); + + $this->assertEquals($rows[0], $result->firstRow()); + } + + public function testStats() + { + $result = new Result(['stats' => 'foo'], []); + $this->assertEquals('foo', $result->stats()); + } + + public function testInfo() + { + $info = ['foo' => 'bar']; + $result = new Result($info, []); + + $this->assertEquals($info, $result->info()); + } + + public function testTransaction() + { + $result = new Result([], [], [ + 'transaction' => 'foo' + ]); + + $this->assertEquals('foo', $result->transaction()); + + $result = new Result([], []); + + $this->assertNull($result->transaction()); + } + + public function testSnapshot() + { + $result = new Result([], [], [ + 'snapshot' => 'foo' + ]); + + $this->assertEquals('foo', $result->snapshot()); + + $result = new Result([], []); + + $this->assertNull($result->snapshot()); + } +} diff --git a/tests/unit/Spanner/SnapshotTest.php b/tests/unit/Spanner/SnapshotTest.php new file mode 100644 index 000000000000..cf54179ce9ed --- /dev/null +++ b/tests/unit/Spanner/SnapshotTest.php @@ -0,0 +1,48 @@ +timestamp = new Timestamp(new \DateTime); + $this->snapshot = new Snapshot( + $this->prophesize(Operation::class)->reveal(), + $this->prophesize(Session::class)->reveal(), + 'foo', + $this->timestamp + ); + } + + public function testReadTimestamp() + { + $this->assertEquals($this->timestamp, $this->snapshot->readTimestamp()); + } +} diff --git a/tests/unit/Spanner/SpannerClientTest.php b/tests/unit/Spanner/SpannerClientTest.php new file mode 100644 index 000000000000..b58b8ff37b90 --- /dev/null +++ b/tests/unit/Spanner/SpannerClientTest.php @@ -0,0 +1,288 @@ +markTestSkipped('Must have the grpc extension installed to run this test.'); + } + + $this->connection = $this->prophesize(ConnectionInterface::class); + $this->client = \Google\Cloud\Dev\stub(SpannerClient::class, [ + ['projectId' => self::PROJECT] + ]); + } + + /** + * @group spanneradmin + */ + public function testConfigurations() + { + $this->connection->listConfigs(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'instanceConfigs' => [ + [ + 'name' => 'projects/'. self::PROJECT .'/instanceConfigs/'. self::CONFIG, + 'displayName' => 'Bar' + ], [ + 'name' => 'projects/'. self::PROJECT .'/instanceConfigs/'. self::CONFIG, + 'displayName' => 'Bat' + ] + ] + ]); + + $this->client->___setProperty('connection', $this->connection->reveal()); + + $configs = $this->client->configurations(); + + $this->assertInstanceOf(ItemIterator::class, $configs); + + $configs = iterator_to_array($configs); + $this->assertEquals(2, count($configs)); + $this->assertInstanceOf(Configuration::class, $configs[0]); + $this->assertInstanceOf(Configuration::class, $configs[1]); + } + + /** + * @group spanneradmin + */ + public function testPagedConfigurations() + { + $firstCall = [ + 'instanceConfigs' => [ + [ + 'name' => 'projects/foo/instanceConfigs/bar', + 'displayName' => 'Bar' + ] + ], + 'nextPageToken' => 'fooBar' + ]; + + $secondCall = [ + 'instanceConfigs' => [ + [ + 'name' => 'projects/foo/instanceConfigs/bat', + 'displayName' => 'Bat' + ] + ] + ]; + + $this->connection->listConfigs(Argument::any()) + ->shouldBeCalledTimes(2) + ->willReturn($firstCall, $secondCall); + + $this->client->___setProperty('connection', $this->connection->reveal()); + + $configs = $this->client->configurations(); + + $this->assertInstanceOf(ItemIterator::class, $configs); + + $configs = iterator_to_array($configs); + $this->assertEquals(2, count($configs)); + $this->assertInstanceOf(Configuration::class, $configs[0]); + $this->assertInstanceOf(Configuration::class, $configs[1]); + } + + /** + * @group spanneradmin + */ + public function testConfiguration() + { + $config = $this->client->configuration('bar'); + + $this->assertInstanceOf(Configuration::class, $config); + $this->assertEquals('bar', $config->name()); + } + + /** + * @group spanneradmin + */ + public function testCreateInstance() + { + $this->connection->createInstance(Argument::that(function ($arg) { + if ($arg['name'] !== 'projects/'. self::PROJECT .'/instances/'. self::INSTANCE) return false; + if ($arg['config'] !== 'projects/'. self::PROJECT .'/instanceConfigs/'. self::CONFIG) return false; + + return true; + })) + ->shouldBeCalled() + ->willReturn([ + 'name' => 'operations/foo' + ]); + + $this->client->___setProperty('connection', $this->connection->reveal()); + + $config = $this->prophesize(Configuration::class); + $config->name()->willReturn(self::CONFIG); + + $operation = $this->client->createInstance($config->reveal(), self::INSTANCE); + + $this->assertInstanceOf(LongRunningOperation::class, $operation); + } + + /** + * @group spanneradmin + */ + public function testInstance() + { + $i = $this->client->instance('foo'); + $this->assertInstanceOf(Instance::class, $i); + $this->assertEquals('foo', $i->name()); + } + + /** + * @group spanneradmin + */ + public function testInstanceWithInstanceArray() + { + $i = $this->client->instance('foo', ['key' => 'val']); + $this->assertEquals('val', $i->info()['key']); + } + + /** + * @group spanneradmin + */ + public function testInstances() + { + $this->connection->listInstances(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'instances' => [ + ['name' => 'projects/test-project/instances/foo'], + ['name' => 'projects/test-project/instances/bar'], + ] + ]); + + $this->client->___setProperty('connection', $this->connection->reveal()); + + $instances = $this->client->instances(); + $this->assertInstanceOf(ItemIterator::class, $instances); + + $instances = iterator_to_array($instances); + $this->assertEquals(2, count($instances)); + $this->assertEquals('foo', $instances[0]->name()); + $this->assertEquals('bar', $instances[1]->name()); + } + + /** + * @group spanneradmin + */ + public function testResumeOperation() + { + $opName = 'operations/foo'; + + $op = $this->client->resumeOperation($opName); + $this->assertInstanceOf(LongRunningOperation::class, $op); + $this->assertEquals($op->name(), $opName); + } + + public function testConnect() + { + $database = $this->client->connect(self::INSTANCE, self::DATABASE); + $this->assertInstanceOf(Database::class, $database); + $this->assertEquals(self::DATABASE, $database->name()); + } + + public function testConnectWithInstance() + { + $inst = $this->client->instance(self::INSTANCE); + $database = $this->client->connect($inst, self::DATABASE); + $this->assertInstanceOf(Database::class, $database); + $this->assertEquals(self::DATABASE, $database->name()); + } + + public function testKeyset() + { + $ks = $this->client->keySet(); + $this->assertInstanceOf(KeySet::class, $ks); + } + + public function testKeyRange() + { + $kr = $this->client->keyRange(); + $this->assertInstanceOf(KeyRange::class, $kr); + } + + public function testBytes() + { + $b = $this->client->bytes('foo'); + $this->assertInstanceOf(Bytes::class, $b); + $this->assertEquals(base64_encode('foo'), (string)$b); + } + + public function testDate() + { + $d = $this->client->date(new \DateTime); + $this->assertInstanceOf(Date::class, $d); + } + + public function testTimestamp() + { + $ts = $this->client->timestamp(new \DateTime); + $this->assertInstanceOf(Timestamp::class, $ts); + } + + public function testInt64() + { + $i64 = $this->client->int64('123'); + $this->assertInstanceOf(Int64::class, $i64); + } + + public function testDuration() + { + $d = $this->client->duration(10, 1); + $this->assertInstanceOf(Duration::class, $d); + } + + public function testSessionClient() + { + $sc = $this->client->sessionClient(); + $this->assertInstanceOf(SessionClient::class, $sc); + } +} diff --git a/tests/unit/Spanner/TimestampTest.php b/tests/unit/Spanner/TimestampTest.php new file mode 100644 index 000000000000..c18ee94e95c2 --- /dev/null +++ b/tests/unit/Spanner/TimestampTest.php @@ -0,0 +1,61 @@ +dt = new \DateTime('1989-10-11 08:58:00 +00:00'); + $this->ts = new Timestamp($this->dt); + } + + public function testGet() + { + $this->assertEquals($this->dt, $this->ts->get()); + } + + public function testFormatAsString() + { + $this->assertEquals( + (new \DateTime($this->dt->format(Timestamp::FORMAT)))->format('U'), + (new \DateTime($this->ts->formatAsString()))->format('U') + ); + } + + public function testCast() + { + $this->assertEquals( + (new \DateTime($this->dt->format(Timestamp::FORMAT)))->format('U'), + (new \DateTime((string)$this->ts))->format('U') + ); + } + + public function testType() + { + $this->assertTrue(is_integer($this->ts->type())); + } +} diff --git a/tests/unit/Spanner/TransactionConfigurationTraitTest.php b/tests/unit/Spanner/TransactionConfigurationTraitTest.php new file mode 100644 index 000000000000..2dd3f408e0b3 --- /dev/null +++ b/tests/unit/Spanner/TransactionConfigurationTraitTest.php @@ -0,0 +1,185 @@ +impl = new TransactionConfigurationTraitImplementation; + $this->ts = new Timestamp(new \DateTime(self::TIMESTAMP), self::NANOS); + $this->duration = new Duration(10,1); + $this->dur = ['seconds' => 10, 'nanos' => 1]; + } + + public function testTransactionSelectorBasicSnapshot() + { + $args = []; + $res = $this->impl->proxyTransactionSelector($args); + $this->assertEquals(SessionPoolInterface::CONTEXT_READ, $res[1]); + $this->assertTrue($res[0]['singleUse']['readOnly']['strong']); + } + + public function testTransactionSelectorExistingId() + { + $args = ['transactionId' => self::TRANSACTION]; + $res = $this->impl->proxyTransactionSelector($args); + $this->assertEquals(SessionPoolInterface::CONTEXT_READ, $res[1]); + $this->assertEquals(self::TRANSACTION, $res[0]['id']); + } + + public function testTransactionSelectorReadWrite() + { + $args = ['transactionType' => SessionPoolInterface::CONTEXT_READWRITE]; + $res = $this->impl->proxyTransactionSelector($args); + $this->assertEquals(SessionPoolInterface::CONTEXT_READWRITE, $res[1]); + $this->assertEquals($this->impl->proxyConfigureTransactionOptions(), $res[0]['singleUse']); + } + + public function testBegin() + { + $args = ['begin' => true]; + $res = $this->impl->proxyTransactionSelector($args); + $this->assertEquals(SessionPoolInterface::CONTEXT_READ, $res[1]); + $this->assertTrue($res[0]['begin']['readOnly']['strong']); + } + + public function testConfigureSnapshotOptionsReturnReadTimestamp() + { + $args = ['returnReadTimestamp' => true]; + $res = $this->impl->proxyConfigureSnapshotOptions($args); + $this->assertTrue($res['readOnly']['returnReadTimestamp']); + } + + public function testConfigureSnapshotOptionsStrong() + { + $args = ['strong' => true]; + $res = $this->impl->proxyConfigureSnapshotOptions($args); + $this->assertTrue($res['readOnly']['strong']); + } + + public function testConfigureSnapshotOptionsMinReadTimestamp() + { + $args = ['minReadTimestamp' => $this->ts]; + $res = $this->impl->proxyConfigureSnapshotOptions($args); + $this->assertEquals(self::TIMESTAMP, $res['readOnly']['minReadTimestamp']); + } + + public function testConfigureSnapshotOptionsReadTimestamp() + { + $args = ['readTimestamp' => $this->ts]; + $res = $this->impl->proxyConfigureSnapshotOptions($args); + $this->assertEquals(self::TIMESTAMP, $res['readOnly']['readTimestamp']); + } + + public function testConfigureSnapshotOptionsMaxStaleness() + { + $args = ['maxStaleness' => $this->duration]; + $res = $this->impl->proxyConfigureSnapshotOptions($args); + $this->assertEquals($this->dur, $res['readOnly']['maxStaleness']); + } + + public function testConfigureSnapshotOptionsExactStaleness() + { + $args = ['exactStaleness' => $this->duration]; + $res = $this->impl->proxyConfigureSnapshotOptions($args); + $this->assertEquals($this->dur, $res['readOnly']['exactStaleness']); + } + + /** + * @expectedException BadMethodCallException + */ + public function testTransactionSelectorInvalidContext() + { + $args = ['transactionType' => 'foo']; + $this->impl->proxyTransactionSelector($args); + } + + /** + * @expectedException BadMethodCallException + */ + public function testConfigureSnapshotOptionsInvalidExactStaleness() + { + $args = ['exactStaleness' => 'foo']; + $this->impl->proxyConfigureSnapshotOptions($args); + } + + /** + * @expectedException BadMethodCallException + */ + public function testConfigureSnapshotOptionsInvalidMaxStaleness() + { + $args = ['maxStaleness' => 'foo']; + $this->impl->proxyConfigureSnapshotOptions($args); + } + + /** + * @expectedException BadMethodCallException + */ + public function testConfigureSnapshotOptionsInvalidMinReadTimestamp() + { + $args = ['minReadTimestamp' => 'foo']; + $this->impl->proxyConfigureSnapshotOptions($args); + } + + /** + * @expectedException BadMethodCallException + */ + public function testConfigureSnapshotOptionsInvalidReadTimestamp() + { + $args = ['readTimestamp' => 'foo']; + $this->impl->proxyConfigureSnapshotOptions($args); + } +} + +class TransactionConfigurationTraitImplementation +{ + use TransactionConfigurationTrait; + + public function proxyTransactionSelector(array &$options) + { + return $this->transactionSelector($options); + } + + public function proxyConfigureTransactionOptions() + { + return $this->configureTransactionOptions(); + } + + public function proxyConfigureSnapshotOptions(array &$options) + { + return $this->configureSnapshotOptions($options); + } +} diff --git a/tests/unit/Spanner/TransactionTest.php b/tests/unit/Spanner/TransactionTest.php new file mode 100644 index 000000000000..867ae378dbb2 --- /dev/null +++ b/tests/unit/Spanner/TransactionTest.php @@ -0,0 +1,324 @@ +connection = $this->prophesize(ConnectionInterface::class); + $this->operation = new Operation($this->connection->reveal(), false); + + $this->session = new Session( + $this->connection->reveal(), + self::PROJECT, + self::INSTANCE, + self::DATABASE, + self::SESSION + ); + + $args = [ + $this->operation, + $this->session, + self::TRANSACTION, + ]; + + $props = [ + 'operation', 'readTimestamp', 'state' + ]; + + $this->transaction = \Google\Cloud\Dev\stub(Transaction::class, $args, $props); + } + + public function testInsert() + { + $this->transaction->insert('Posts', ['foo' => 'bar']); + + $mutations = $this->transaction->___getProperty('mutations'); + + $this->assertEquals('Posts', $mutations[0]['insert']['table']); + $this->assertEquals('foo', $mutations[0]['insert']['columns'][0]); + $this->assertEquals('bar', $mutations[0]['insert']['values'][0]); + } + + public function testInsertBatch() + { + $this->transaction->insertBatch('Posts', [['foo' => 'bar']]); + + $mutations = $this->transaction->___getProperty('mutations'); + + $this->assertEquals('Posts', $mutations[0]['insert']['table']); + $this->assertEquals('foo', $mutations[0]['insert']['columns'][0]); + $this->assertEquals('bar', $mutations[0]['insert']['values'][0]); + } + + public function testUpdate() + { + $this->transaction->update('Posts', ['foo' => 'bar']); + + $mutations = $this->transaction->___getProperty('mutations'); + + $this->assertEquals('Posts', $mutations[0]['update']['table']); + $this->assertEquals('foo', $mutations[0]['update']['columns'][0]); + $this->assertEquals('bar', $mutations[0]['update']['values'][0]); + } + + public function testUpdateBatch() + { + $this->transaction->updateBatch('Posts', [['foo' => 'bar']]); + + $mutations = $this->transaction->___getProperty('mutations'); + + $this->assertEquals('Posts', $mutations[0]['update']['table']); + $this->assertEquals('foo', $mutations[0]['update']['columns'][0]); + $this->assertEquals('bar', $mutations[0]['update']['values'][0]); + } + + public function testInsertOrUpdate() + { + $this->transaction->insertOrUpdate('Posts', ['foo' => 'bar']); + + $mutations = $this->transaction->___getProperty('mutations'); + + $this->assertEquals('Posts', $mutations[0]['insertOrUpdate']['table']); + $this->assertEquals('foo', $mutations[0]['insertOrUpdate']['columns'][0]); + $this->assertEquals('bar', $mutations[0]['insertOrUpdate']['values'][0]); + } + + public function testInsertOrUpdateBatch() + { + $this->transaction->insertOrUpdateBatch('Posts', [['foo' => 'bar']]); + + $mutations = $this->transaction->___getProperty('mutations'); + + $this->assertEquals('Posts', $mutations[0]['insertOrUpdate']['table']); + $this->assertEquals('foo', $mutations[0]['insertOrUpdate']['columns'][0]); + $this->assertEquals('bar', $mutations[0]['insertOrUpdate']['values'][0]); + } + + public function testReplace() + { + $this->transaction->replace('Posts', ['foo' => 'bar']); + + $mutations = $this->transaction->___getProperty('mutations'); + + $this->assertEquals('Posts', $mutations[0]['replace']['table']); + $this->assertEquals('foo', $mutations[0]['replace']['columns'][0]); + $this->assertEquals('bar', $mutations[0]['replace']['values'][0]); + } + + public function testReplaceBatch() + { + $this->transaction->replaceBatch('Posts', [['foo' => 'bar']]); + + $mutations = $this->transaction->___getProperty('mutations'); + + $this->assertEquals('Posts', $mutations[0]['replace']['table']); + $this->assertEquals('foo', $mutations[0]['replace']['columns'][0]); + $this->assertEquals('bar', $mutations[0]['replace']['values'][0]); + } + + public function testDelete() + { + $this->transaction->delete('Posts', new KeySet(['keys' => ['foo']])); + + $mutations = $this->transaction->___getProperty('mutations'); + $this->assertEquals('Posts', $mutations[0]['delete']['table']); + $this->assertEquals('foo', $mutations[0]['delete']['keySet']['keys'][0]); + $this->assertFalse($mutations[0]['delete']['keySet']['all']); + } + + public function testExecute() + { + $sql = 'SELECT * FROM Table'; + + $this->connection->executeSql(Argument::that(function ($arg) use ($sql) { + if ($arg['transaction']['id'] !== self::TRANSACTION) return false; + if ($arg['sql'] !== $sql) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'ID', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'rows' => [ + [ + '10' + ] + ] + ]); + + $this->refreshOperation(); + + $res = $this->transaction->execute($sql); + $this->assertInstanceOf(Result::class, $res); + $this->assertEquals(10, $res->rows()[0]['ID']); + } + + public function testRead() + { + $table = 'Table'; + $opts = ['foo' => 'bar']; + + $this->connection->read(Argument::that(function ($arg) use ($table, $opts) { + if ($arg['transaction']['id'] !== self::TRANSACTION) return false; + if ($arg['table'] !== $table) return false; + if ($arg['keySet']['all'] !== true) return false; + if ($arg['columns'] !== ['ID']) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'ID', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'rows' => [ + [ + '10' + ] + ] + ]); + + $this->refreshOperation(); + + $res = $this->transaction->read($table, new KeySet(['all' => true]), ['ID']); + $this->assertInstanceOf(Result::class, $res); + $this->assertEquals(10, $res->rows()[0]['ID']); + } + + public function testCommit() + { + $this->transaction->insert('Posts', ['foo' => 'bar']); + + $mutations = $this->transaction->___getProperty('mutations'); + + $operation = $this->prophesize(Operation::class); + $operation->commit($this->session, $mutations, ['transactionId' => self::TRANSACTION])->shouldBeCalled(); + + $this->transaction->___setProperty('operation', $operation->reveal()); + + $this->transaction->commit(); + } + + /** + * @expectedException RuntimeException + */ + public function testCommitInvalidState() + { + $this->transaction->___setProperty('state', 'foo'); + $this->transaction->commit(); + } + + public function testRollback() + { + $this->connection->rollback(Argument::any()) + ->shouldBeCalled(); + + $this->refreshOperation(); + + $this->transaction->rollback(); + } + + /** + * @expectedException RuntimeException + */ + public function testRollbackInvalidState() + { + $this->transaction->___setProperty('state', 'foo'); + $this->transaction->rollback(); + } + + public function testId() + { + $this->assertEquals(self::TRANSACTION, $this->transaction->id()); + } + + public function testState() + { + $this->assertEquals(Transaction::STATE_ACTIVE, $this->transaction->state()); + + $this->transaction->___setProperty('state', Transaction::STATE_COMMITTED); + $this->assertEquals(Transaction::STATE_COMMITTED, $this->transaction->state()); + } + + // ******* + // Helpers + + private function refreshOperation() + { + $operation = new Operation($this->connection->reveal(), false); + $this->transaction->___setProperty('operation', $operation); + } + + private function commitResponse() + { + return ['commitTimestamp' => self::TIMESTAMP]; + } + + private function assertTimestampIsCorrect($res) + { + $ts = new \DateTimeImmutable($this->commitResponse()['commitTimestamp']); + + $this->assertEquals($ts->format('Y-m-d\TH:i:s\Z'), $res->get()->format('Y-m-d\TH:i:s\Z')); + } +} diff --git a/tests/unit/Spanner/ValueMapperTest.php b/tests/unit/Spanner/ValueMapperTest.php new file mode 100644 index 000000000000..404ba6a3201e --- /dev/null +++ b/tests/unit/Spanner/ValueMapperTest.php @@ -0,0 +1,385 @@ +mapper = new ValueMapper(false); + } + + public function testFormatParamsForExecuteSqlSimpleTypes() + { + $params = [ + 'id' => 1, + 'name' => 'john', + 'pi' => 3.1515, + 'isCool' => false, + ]; + + $res = $this->mapper->formatParamsForExecuteSql($params); + + $this->assertEquals($params, $res['params']); + $this->assertEquals(ValueMapper::TYPE_INT64, $res['paramTypes']['id']['code']); + $this->assertEquals(ValueMapper::TYPE_STRING, $res['paramTypes']['name']['code']); + $this->assertEquals(ValueMapper::TYPE_FLOAT64, $res['paramTypes']['pi']['code']); + $this->assertEquals(ValueMapper::TYPE_BOOL, $res['paramTypes']['isCool']['code']); + } + + public function testFormatParamsForExecuteSqlResource() + { + $c = 'hello world'; + + $resource = fopen('php://temp', 'r+'); + fwrite($resource, $c); + rewind($resource); + + $params = [ + 'resource' => $resource + ]; + + $res = $this->mapper->formatParamsForExecuteSql($params); + + $this->assertEquals($c, base64_decode($res['params']['resource'])); + $this->assertEquals(ValueMapper::TYPE_BYTES, $res['paramTypes']['resource']['code']); + } + + public function testFormatParamsForExecuteSqlArray() + { + $params = [ + 'array' => ['foo', 'bar'] + ]; + + $res = $this->mapper->formatParamsForExecuteSql($params); + + $this->assertEquals('foo', $res['params']['array'][0]); + $this->assertEquals('bar', $res['params']['array'][1]); + $this->assertEquals(ValueMapper::TYPE_ARRAY, $res['paramTypes']['array']['code']); + $this->assertEquals(ValueMapper::TYPE_STRING, $res['paramTypes']['array']['arrayElementType']['code']); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testFormatParamsForExecuteSqlArrayInvalidAssoc() + { + $this->mapper->formatParamsForExecuteSql(['array' => [ + 'foo' => 'bar' + ]]); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testFormatParamsForExecuteSqlInvalidTypes() + { + $this->mapper->formatParamsForExecuteSql(['array' => ['foo', 3.1515]]); + } + + public function testFormatParamsForExecuteSqlInt64() + { + $val = '1234'; + $params = [ + 'int' => new Int64($val) + ]; + + $res = $this->mapper->formatParamsForExecuteSql($params); + + $this->assertEquals($val, $res['params']['int']); + $this->assertEquals(ValueMapper::TYPE_INT64, $res['paramTypes']['int']['code']); + } + + public function testFormatParamsForExecuteSqlValueInterface() + { + $val = 'hello world'; + $params = [ + 'bytes' => new Bytes($val) + ]; + + $res = $this->mapper->formatParamsForExecuteSql($params); + $this->assertEquals($val, base64_decode($res['params']['bytes'])); + $this->assertEquals(ValueMapper::TYPE_BYTES, $res['paramTypes']['bytes']['code']); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testFormatParamsForExecuteSqlInvalidObjectType() + { + $params = [ + 'bad' => $this + ]; + + $this->mapper->formatParamsForExecuteSql($params); + } + + public function testEncodeValuesAsSimpleType() + { + $dt = new \DateTime; + + $vals = []; + $vals['bool'] = true; + $vals['int'] = 555555; + $vals['intObj'] = new Int64((string) $vals['int']); + $vals['float'] = 3.1415; + $vals['nan'] = NAN; + $vals['inf'] = INF; + $vals['timestamp'] = new Timestamp($dt); + $vals['date'] = new Date($dt); + $vals['string'] = 'foo'; + $vals['bytes'] = new Bytes('hello world'); + $vals['array'] = ['foo', 'bar']; + + $res = $this->mapper->encodeValuesAsSimpleType($vals); + + $this->assertTrue($res[0]); + $this->assertEquals((string) $vals['int'], $res[1]); + $this->assertEquals((string) $vals['int'], $res[2]); + $this->assertEquals($vals['float'], $res[3]); + $this->assertTrue(is_nan($res[4])); + $this->assertEquals(INF, $res[5]); + $this->assertEquals($dt->format(Timestamp::FORMAT), $res[6]); + $this->assertEquals($dt->format(Date::FORMAT), $res[7]); + $this->assertEquals($vals['string'], $res[8]); + $this->assertEquals(base64_encode('hello world'), $res[9]); + $this->assertEquals($vals['array'], $res[10]); + } + + public function testDecodeValuesBool() + { + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_BOOL), + $this->createRow(false) + ); + $this->assertEquals(false, $res['rowName']); + } + + public function testDecodeValuesInt() + { + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_INT64), + $this->createRow('555') + ); + $this->assertEquals(555, $res['rowName']); + } + + public function testDecodeValuesInt64Object() + { + $mapper = new ValueMapper(true); + $res = $mapper->decodeValues( + $this->createField(ValueMapper::TYPE_INT64), + $this->createRow('555') + ); + $this->assertInstanceOf(Int64::class, $res['rowName']); + $this->assertEquals('555', $res['rowName']->get()); + } + + public function testDecodeValuesFloat() + { + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_FLOAT64), + $this->createRow(3.1415) + ); + $this->assertEquals(3.1415, $res['rowName']); + } + + public function testDecodeValuesFloatNaN() + { + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_FLOAT64), + $this->createRow('NaN') + ); + $this->assertTrue(is_nan($res['rowName'])); + } + + public function testDecodeValuesFloatInfinity() + { + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_FLOAT64), + $this->createRow('Infinity') + ); + + $this->assertTrue(is_infinite($res['rowName'])); + $this->assertTrue($res['rowName'] > 0); + } + + public function testDecodeValuesFloatNegativeInfinity() + { + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_FLOAT64), + $this->createRow('-Infinity') + ); + + $this->assertTrue(is_infinite($res['rowName'])); + $this->assertTrue($res['rowName'] < 0); + } + + /** + * @expectedException RuntimeException + */ + public function testDecodeValuesFloatError() + { + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_FLOAT64), + $this->createRow('foo') + ); + } + + public function testDecodeValuesString() + { + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_STRING), + $this->createRow('foo') + ); + $this->assertEquals('foo', $res['rowName']); + } + + public function testDecodeValuesTimestamp() + { + $dt = new \DateTime; + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_TIMESTAMP), + $this->createRow($dt->format(Timestamp::FORMAT)) + ); + + $this->assertInstanceOf(Timestamp::class, $res['rowName']); + $this->assertEquals($dt->format(Timestamp::FORMAT), $res['rowName']->formatAsString()); + } + + public function testDecodeValuesDate() + { + $dt = new \DateTime; + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_DATE), + $this->createRow($dt->format(Date::FORMAT)) + ); + + $this->assertInstanceOf(Date::class, $res['rowName']); + $this->assertEquals($dt->format(Date::FORMAT), $res['rowName']->formatAsString()); + } + + public function testDecodeValuesBytes() + { + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_BYTES), + $this->createRow(base64_encode('hello world')) + ); + + $this->assertInstanceOf(Bytes::class, $res['rowName']); + $this->assertEquals('hello world', $res['rowName']->get()); + } + + public function testDecodeValuesArray() + { + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_ARRAY, 'arrayElementType', [ + 'code' => ValueMapper::TYPE_STRING + ]), $this->createRow(['foo', 'bar']) + ); + + $this->assertEquals('foo', $res['rowName'][0]); + $this->assertEquals('bar', $res['rowName'][1]); + } + + public function testDecodeValuesStruct() + { + $field = [ + 'name' => 'structTest', + 'type' => [ + 'code' => ValueMapper::TYPE_ARRAY, + 'arrayElementType' => [ + 'code' => ValueMapper::TYPE_STRUCT, + 'structType' => [ + 'fields' => [ + [ + 'name' => 'rowName', + 'type' => [ + 'code' => ValueMapper::TYPE_STRING + ] + ] + ] + ] + ] + ] + ]; + + $row = [ + [ + 'Hello World' + ] + ]; + + $res = $this->mapper->decodeValues( + [$field], + [$row] + ); + + $this->assertEquals('Hello World', $res['structTest'][0]['rowName']); + } + + public function testDecodeValuesAnonymousField() + { + $fields = [ + [ + 'name' => 'ID', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64, + ] + ], [ + 'type' => [ + 'code' => ValueMapper::TYPE_STRING + ] + ] + ]; + + $row = ['1337', 'John']; + + $res = $this->mapper->decodeValues($fields, $row); + + $this->assertEquals('1337', $res['ID']); + $this->assertEquals('John', $res[1]); + } + + private function createField($code, $type = null, array $typeObj = []) + { + return [[ + 'name' => 'rowName', + 'type' => array_filter([ + 'code' => $code, + $type => $typeObj + ]) + ]]; + } + + private function createRow($val) + { + return [$val]; + } +} diff --git a/tests/unit/fixtures/spanner/instance.json b/tests/unit/fixtures/spanner/instance.json new file mode 100644 index 000000000000..fcf371769ce3 --- /dev/null +++ b/tests/unit/fixtures/spanner/instance.json @@ -0,0 +1,7 @@ +{ + "name": "projects\/test-project\/instances\/instance-name", + "config": "projects\/test-project\/instanceConfigs\/regional-europe-west1", + "displayName": "Instance Name", + "nodeCount": 1, + "state": 2 +} From 6636b40318fd3146b70525ef14d8c43288508a19 Mon Sep 17 00:00:00 2001 From: Dave Supplee Date: Fri, 7 Apr 2017 12:32:14 -0400 Subject: [PATCH 02/11] introduce cache based session pool and streaming execute/read --- composer.json | 6 +- src/Core/GrpcRequestWrapper.php | 24 +- src/Core/GrpcTrait.php | 2 +- src/Core/Lock/LockInterface.php | 47 ++ src/Core/Lock/SymfonyLockAdapter.php | 95 +++ src/Core/PhpArray.php | 23 +- src/Core/composer.json | 5 +- src/Spanner/Configuration.php | 5 +- .../Connection/ConnectionInterface.php | 6 +- src/Spanner/Connection/Grpc.php | 11 +- src/Spanner/Database.php | 279 +++++++-- src/Spanner/Duration.php | 2 +- src/Spanner/Instance.php | 25 +- src/Spanner/KeyRange.php | 5 +- src/Spanner/Operation.php | 111 ++-- src/Spanner/Result.php | 267 ++++++--- src/Spanner/Session/CacheSessionPool.php | 547 ++++++++++++++++++ src/Spanner/Session/Session.php | 42 +- src/Spanner/Session/SessionClient.php | 104 ---- src/Spanner/Session/SessionPool.php | 63 -- src/Spanner/Session/SessionPoolInterface.php | 31 +- src/Spanner/Session/SimpleSessionPool.php | 54 -- src/Spanner/Snapshot.php | 9 +- src/Spanner/SpannerClient.php | 45 +- src/Spanner/Transaction.php | 18 +- src/Spanner/ValueMapper.php | 10 +- tests/snippets/Spanner/DatabaseTest.php | 126 ++-- tests/snippets/Spanner/InstanceTest.php | 2 - tests/snippets/Spanner/ResultTest.php | 110 ++++ tests/snippets/Spanner/SnapshotTest.php | 29 +- tests/snippets/Spanner/SpannerClientTest.php | 10 - tests/snippets/Spanner/TransactionTest.php | 63 +- tests/snippets/Speech/SpeechClientTest.php | 4 +- tests/system/Spanner/OperationsTest.php | 4 +- tests/system/Spanner/SnapshotTest.php | 6 +- tests/unit/Core/PhpArrayTest.php | 2 +- tests/unit/Spanner/DatabaseTest.php | 69 +-- tests/unit/Spanner/InstanceTest.php | 1 - tests/unit/Spanner/OperationTest.php | 24 +- tests/unit/Spanner/ResultTest.php | 139 +++-- .../Spanner/Session/CacheSessionPoolTest.php | 471 +++++++++++++++ tests/unit/Spanner/SpannerClientTest.php | 7 - tests/unit/Spanner/TransactionTest.php | 72 +-- .../streaming-read-acceptance-test.json | 371 ++++++++++++ 44 files changed, 2574 insertions(+), 772 deletions(-) create mode 100644 src/Core/Lock/LockInterface.php create mode 100644 src/Core/Lock/SymfonyLockAdapter.php create mode 100644 src/Spanner/Session/CacheSessionPool.php delete mode 100644 src/Spanner/Session/SessionClient.php delete mode 100644 src/Spanner/Session/SessionPool.php delete mode 100644 src/Spanner/Session/SimpleSessionPool.php create mode 100644 tests/snippets/Spanner/ResultTest.php create mode 100644 tests/unit/Spanner/Session/CacheSessionPoolTest.php create mode 100644 tests/unit/fixtures/spanner/streaming-read-acceptance-test.json diff --git a/composer.json b/composer.json index 4731d19473da..8b061ebcd98f 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "natural language", "pubsub", "pub sub", + "spanner", "speech", "storage", "gcs", @@ -39,13 +40,14 @@ } ], "require": { - "php": ">=5.5", + "php": ">=5.5.9", "rize/uri-template": "~0.3", "google/auth": "^0.11", "guzzlehttp/guzzle": "^5.3|^6.0", "guzzlehttp/psr7": "^1.2", "monolog/monolog": "~1", - "psr/http-message": "1.0.*" + "psr/http-message": "1.0.*", + "symfony/lock": "dev-master" }, "require-dev": { "phpunit/phpunit": "4.8.*", diff --git a/src/Core/GrpcRequestWrapper.php b/src/Core/GrpcRequestWrapper.php index ad42cf7d85ea..e8fcd2fdb98f 100644 --- a/src/Core/GrpcRequestWrapper.php +++ b/src/Core/GrpcRequestWrapper.php @@ -29,6 +29,7 @@ use Google\GAX\OperationResponse; use Google\GAX\PagedListResponse; use Google\GAX\RetrySettings; +use Google\GAX\ServerStream; use Grpc; /** @@ -158,7 +159,7 @@ public function send(callable $request, array $args, array $options = []) * Serializes a gRPC response. * * @param mixed $response - * @return array|null + * @return \Generator|array|null */ private function handleResponse($response) { @@ -174,9 +175,30 @@ private function handleResponse($response) return $response; } + if ($response instanceof ServerStream) { + return $this->handleStream($response); + } + return null; } + /** + * Handles a streaming response. + * + * @param ServerStream $response + * @return \Generator|array|null + */ + private function handleStream(ServerStream $response) + { + try { + foreach ($response->readAll() as $count => $result) { + yield $result->serialize($this->codec); + } + } catch (\Exception $ex) { + throw $this->convertToGoogleException($ex); + } + } + /** * Convert a GAX exception to a Google Exception. * diff --git a/src/Core/GrpcTrait.php b/src/Core/GrpcTrait.php index 212f527c2e67..4fa5de20aa15 100644 --- a/src/Core/GrpcTrait.php +++ b/src/Core/GrpcTrait.php @@ -52,7 +52,7 @@ public function setRequestWrapper(GrpcRequestWrapper $requestWrapper) * * @param callable $request * @param array $args - * @return array + * @return \Generator|array */ public function send(callable $request, array $args) { diff --git a/src/Core/Lock/LockInterface.php b/src/Core/Lock/LockInterface.php new file mode 100644 index 000000000000..2c0c98bab090 --- /dev/null +++ b/src/Core/Lock/LockInterface.php @@ -0,0 +1,47 @@ +lock = $lock; + } + + /** + * Acquires a lock that will block until released. + * + * @return bool + * @throws \RuntimeException + */ + public function acquire() + { + try { + return $this->lock->acquire(true); + } catch (\Exception $ex) { + throw new \RunTimeException($ex->getMessage()); + } + } + + /** + * Releases the lock. + * + * @throws \RuntimeException + */ + public function release() + { + try { + $this->lock->release(); + } catch (\Exception $ex) { + throw new \RunTimeException($ex->getMessage()); + } + } + + /** + * Execute a callable within a lock. + * + * @return mixed + * @throws \RuntimeException + */ + public function synchronize(callable $func) + { + $result = null; + $exception = null; + + if ($this->acquire()) { + try { + $result = $func(); + } catch (\Exception $ex) { + $exception = $ex; + } + $this->release(); + } + + if ($exception) { + throw $exception; + } + + return $result; + } +} diff --git a/src/Core/PhpArray.php b/src/Core/PhpArray.php index a18ae19f0872..7829afbce919 100644 --- a/src/Core/PhpArray.php +++ b/src/Core/PhpArray.php @@ -18,6 +18,7 @@ namespace Google\Cloud\Core; use DrSlump\Protobuf; +use google\protobuf\Value; use google\protobuf\ListValue; use google\protobuf\NullValue; use google\protobuf\Struct; @@ -180,7 +181,7 @@ protected function filterValue($value, Protobuf\Field $field) $field->getValue(), $field->descriptor()->getFieldByName('value') ); - $vals[$field->getKey()] = current($val); + $vals[$field->getKey()] = $val; } return $vals; @@ -190,11 +191,29 @@ protected function filterValue($value, Protobuf\Field $field) $vals = []; foreach ($value->getValuesList() as $val) { - $vals[] = current($this->encodeMessage($val)); + $fields = $val->descriptor()->getFields(); + + foreach ($fields as $field) { + $name = $field->getName(); + if ($val->$name) { + $vals[] = $this->filterValue($val->$name, $field); + } + } } return $vals; } + + if ($value instanceof Value) { + $fields = $value->descriptor()->getFields(); + + foreach ($fields as $field) { + $name = $field->getName(); + if ($value->$name) { + return $this->filterValue($value->$name, $field); + } + } + } } return parent::filterValue($value, $field); diff --git a/src/Core/composer.json b/src/Core/composer.json index cee03e142b4a..2d27262dc7c1 100644 --- a/src/Core/composer.json +++ b/src/Core/composer.json @@ -4,13 +4,14 @@ "license": "Apache-2.0", "minimum-stability": "stable", "require": { - "php": ">=5.5", + "php": ">=5.5.9", "rize/uri-template": "~0.3", "google/auth": "^0.11", "guzzlehttp/guzzle": "^5.3|^6.0", "guzzlehttp/psr7": "^1.2", "monolog/monolog": "~1", - "psr/http-message": "1.0.*" + "psr/http-message": "1.0.*", + "symfony/lock": "dev-master" }, "extra": { "component": { diff --git a/src/Spanner/Configuration.php b/src/Spanner/Configuration.php index d8eea3e9aead..1fbc1da9a4b1 100644 --- a/src/Spanner/Configuration.php +++ b/src/Spanner/Configuration.php @@ -26,10 +26,9 @@ * * Example: * ``` - * use Google\Cloud\ServiceBuilder; + * use Google\Cloud\Spanner\SpannerClient; * - * $cloud = new ServiceBuilder(); - * $spanner = $cloud->spanner(); + * $spanner = new SpannerClient(); * * $configuration = $spanner->configuration('regional-europe-west'); * ``` diff --git a/src/Spanner/Connection/ConnectionInterface.php b/src/Spanner/Connection/ConnectionInterface.php index 70e28b043b5b..12de8323345a 100644 --- a/src/Spanner/Connection/ConnectionInterface.php +++ b/src/Spanner/Connection/ConnectionInterface.php @@ -129,13 +129,15 @@ public function deleteSession(array $args = []); /** * @param array $args [optional] + * @return \Generator */ - public function executeSql(array $args = []); + public function executeStreamingSql(array $args = []); /** * @param array $args [optional] + * @return \Generator */ - public function read(array $args = []); + public function streamingRead(array $args = []); /** * @param array $args [optional] diff --git a/src/Spanner/Connection/Grpc.php b/src/Spanner/Connection/Grpc.php index fdd69550d09e..532ea335599a 100644 --- a/src/Spanner/Connection/Grpc.php +++ b/src/Spanner/Connection/Grpc.php @@ -134,7 +134,6 @@ public function __construct(array $config = []) $this->databaseAdminClient = new DatabaseAdminClient($grpcConfig); $this->spannerClient = new SpannerClient($grpcConfig); $this->operationsClient = $this->instanceAdminClient->getOperationsClient(); - $this->longRunningGrpcClients = [ $this->instanceAdminClient, $this->databaseAdminClient @@ -399,8 +398,9 @@ public function deleteSession(array $args = []) /** * @param array $args [optional] + * @return \Generator */ - public function executeSql(array $args = []) + public function executeStreamingSql(array $args = []) { $params = $this->pluck('params', $args); if ($params) { @@ -415,7 +415,7 @@ public function executeSql(array $args = []) $args['transaction'] = $this->createTransactionSelector($args); - return $this->send([$this->spannerClient, 'executeSql'], [ + return $this->send([$this->spannerClient, 'executeStreamingSql'], [ $this->pluck('session', $args), $this->pluck('sql', $args), $args @@ -424,8 +424,9 @@ public function executeSql(array $args = []) /** * @param array $args [optional] + * @return \Generator */ - public function read(array $args = []) + public function streamingRead(array $args = []) { $keySet = $this->pluck('keySet', $args); $keySet = (new KeySet) @@ -433,7 +434,7 @@ public function read(array $args = []) $args['transaction'] = $this->createTransactionSelector($args); - return $this->send([$this->spannerClient, 'read'], [ + return $this->send([$this->spannerClient, 'streamingRead'], [ $this->pluck('session', $args), $this->pluck('table', $args), $this->pluck('columns', $args), diff --git a/src/Spanner/Database.php b/src/Spanner/Database.php index 32ff682b2638..27a5f477fad3 100644 --- a/src/Spanner/Database.php +++ b/src/Spanner/Database.php @@ -26,28 +26,27 @@ use Google\Cloud\Core\Retry; use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Connection\IamDatabase; +use Google\Cloud\Spanner\Session\Session; use Google\Cloud\Spanner\Session\SessionPoolInterface; use Google\Cloud\Spanner\V1\SpannerClient as GrpcSpannerClient; /** - * Represents a Google Cloud Spanner Database + * Represents a Google Cloud Spanner Database. * * Example: * ``` - * use Google\Cloud\ServiceBuilder; + * use Google\Cloud\Spanner\SpannerClient; * - * $cloud = new ServiceBuilder(); - * $spanner = $cloud->spanner(); + * $spanner = new SpannerClient(); * * $database = $spanner->connect('my-instance', 'my-database'); * ``` * * ``` * // Databases can also be connected to via an Instance. - * use Google\Cloud\ServiceBuilder; + * use Google\Cloud\Spanner\SpannerClient; * - * $cloud = new ServiceBuilder(); - * $spanner = $cloud->spanner(); + * $spanner = new SpannerClient(); * * $instance = $spanner->instance('my-instance'); * $database = $instance->database('my-database'); @@ -70,11 +69,6 @@ class Database */ private $instance; - /** - * @var SessionPoolInterface - */ - private $sessionPool; - /** * @var LongRunningConnectionInterface */ @@ -100,40 +94,54 @@ class Database */ private $iam; + /** + * @var Session|null + */ + private $session; + + /** + * @var SessionPoolInterface|null + */ + private $sessionPool; + /** * Create an object representing a Database. * * @param ConnectionInterface $connection The connection to the * Google Cloud Spanner Admin API. * @param Instance $instance The instance in which the database exists. - * @param SessionPoolInterface $sessionPool The session pool implementation. * @param LongRunningConnectionInterface $lroConnection An implementation * mapping to methods which handle LRO resolution in the service. * @param string $projectId The project ID. * @param string $name The database name. - * @param bool $returnInt64AsObject If true, 64 bit integers will be - * returned as a {@see Google\Cloud\Core\Int64} object for 32 bit + * @param SessionPoolInterface $sessionPool [optional] The session pool + * implementation. + * @param bool $returnInt64AsObject [optional If true, 64 bit integers will + * be returned as a {@see Google\Cloud\Core\Int64} object for 32 bit * platform compatibility. **Defaults to** false. */ public function __construct( ConnectionInterface $connection, Instance $instance, - SessionPoolInterface $sessionPool, LongRunningConnectionInterface $lroConnection, array $lroCallables, $projectId, $name, + SessionPoolInterface $sessionPool = null, $returnInt64AsObject = false ) { $this->connection = $connection; $this->instance = $instance; - $this->sessionPool = $sessionPool; $this->lroConnection = $lroConnection; $this->lroCallables = $lroCallables; $this->projectId = $projectId; $this->name = $name; - + $this->sessionPool = $sessionPool; $this->operation = new Operation($connection, $returnInt64AsObject); + + if ($this->sessionPool) { + $this->sessionPool->setDatabase($this); + } } /** @@ -386,7 +394,11 @@ public function snapshot(array $options = []) $session = $this->selectSession(SessionPoolInterface::CONTEXT_READ); - return $this->operation->snapshot($session, $transactionOptions); + try { + return $this->operation->snapshot($session, $transactionOptions); + } finally { + $session->setExpiration(); + } } /** @@ -416,12 +428,13 @@ public function snapshot(array $options = []) * Example: * ``` * $transaction = $database->runTransaction(function (Transaction $t) use ($username, $password) { - * $user = $t->execute('SELECT * FROM Users WHERE Name = @name and PasswordHash = @password', [ + * $rows = $t->execute('SELECT * FROM Users WHERE Name = @name and PasswordHash = @password', [ * 'parameters' => [ * 'name' => $username, * 'password' => password_hash($password, PASSWORD_DEFAULT) * ] - * ])->firstRow(); + * ])->rows(); + * $user = $rows->current(); * * if ($user) { * // Do something here to grant the user access. @@ -500,7 +513,12 @@ public function runTransaction(callable $operation, array $options = []) }; $retry = new Retry($options['maxRetries'], $delayFn); - return $retry->execute($commitFn, [$operation, $session, $options]); + + try { + return $retry->execute($commitFn, [$operation, $session, $options]); + } finally { + $session->setExpiration(); + } } /** @@ -539,7 +557,12 @@ public function transaction(array $options = []) ]; $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); - return $this->operation->transaction($session, $options); + + try { + return $this->operation->transaction($session, $options); + } finally { + $session->setExpiration(); + } } /** @@ -609,7 +632,12 @@ public function insertBatch($table, array $dataSet, array $options = []) $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); $options['singleUseTransaction'] = $this->configureTransactionOptions(); - return $this->operation->commit($session, $mutations, $options); + + try { + return $this->operation->commit($session, $mutations, $options); + } finally { + $session->setExpiration(); + } } /** @@ -684,7 +712,12 @@ public function updateBatch($table, array $dataSet, array $options = []) $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); $options['singleUseTransaction'] = $this->configureTransactionOptions(); - return $this->operation->commit($session, $mutations, $options); + + try { + return $this->operation->commit($session, $mutations, $options); + } finally { + $session->setExpiration(); + } } /** @@ -762,7 +795,12 @@ public function insertOrUpdateBatch($table, array $dataSet, array $options = []) $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); $options['singleUseTransaction'] = $this->configureTransactionOptions(); - return $this->operation->commit($session, $mutations, $options); + + try { + return $this->operation->commit($session, $mutations, $options); + } finally { + $session->setExpiration(); + } } /** @@ -840,7 +878,12 @@ public function replaceBatch($table, array $dataSet, array $options = []) $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); $options['singleUseTransaction'] = $this->configureTransactionOptions(); - return $this->operation->commit($session, $mutations, $options); + + try { + return $this->operation->commit($session, $mutations, $options); + } finally { + $session->setExpiration(); + } } /** @@ -875,7 +918,12 @@ public function delete($table, KeySet $keySet, array $options = []) $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); $options['singleUseTransaction'] = $this->configureTransactionOptions(); - return $this->operation->commit($session, $mutations, $options); + + try { + return $this->operation->commit($session, $mutations, $options); + } finally { + $session->setExpiration(); + } } /** @@ -888,6 +936,10 @@ public function delete($table, KeySet $keySet, array $options = []) * 'postId' => 1337 * ] * ]); + * + * $firstRow = $result + * ->rows() + * ->current(); * ``` * * ``` @@ -899,6 +951,8 @@ public function delete($table, KeySet $keySet, array $options = []) * 'begin' => true * ]); * + * $result->rows()->current(); + * * $snapshot = $result->snapshot(); * ``` * @@ -912,6 +966,8 @@ public function delete($table, KeySet $keySet, array $options = []) * 'transactionType' => SessionPoolInterface::CONTEXT_READWRITE * ]); * + * $result->rows()->current(); + * * $transaction = $result->transaction(); * ``` * @@ -969,7 +1025,11 @@ public function execute($sql, array $options = []) $options['transaction'] = $transactionOptions; $options['transactionContext'] = $context; - return $this->operation->execute($session, $sql, $options); + try { + return $this->operation->execute($session, $sql, $options); + } finally { + $session->setExpiration(); + } } /** @@ -984,6 +1044,10 @@ public function execute($sql, array $options = []) * $columns = ['ID', 'title', 'content']; * * $result = $database->read('Posts', $keySet, $columns); + * + * $firstRow = $result + * ->rows() + * ->current(); * ``` * * ``` @@ -998,6 +1062,8 @@ public function execute($sql, array $options = []) * 'begin' => true * ]); * + * $result->rows()->current(); + * * $snapshot = $result->snapshot(); * ``` * @@ -1014,6 +1080,8 @@ public function execute($sql, array $options = []) * 'transactionType' => SessionPoolInterface::CONTEXT_READWRITE * ]); * + * $result->rows()->current(); + * * $transaction = $result->transaction(); * ``` * @@ -1071,38 +1139,125 @@ public function read($table, KeySet $keySet, array $columns, array $options = [] $options['transaction'] = $transactionOptions; $options['transactionContext'] = $context; - return $this->operation->read($session, $table, $keySet, $columns, $options); + try { + return $this->operation->read($session, $table, $keySet, $columns, $options); + } finally { + $session->setExpiration(); + } + } + + /** + * Get the underlying session pool implementation. + * + * Example: + * ``` + * $pool = $database->sessionPool(); + * ``` + * + * @return SessionPoolInterface|null + */ + public function sessionPool() + { + return $this->sessionPool; + } + + /** + * Closes the database connection by returning the active session back to + * the session pool or by deleting the session if there is no pool + * associated. + * + * It is highly important to ensure this is called as it is not always safe + * to rely soley on {@see Google\Cloud\Spanner\Database::__destruct()}. + * + * Example: + * ``` + * $database->close(); + * ``` + */ + public function close() + { + if ($this->session) { + if ($this->sessionPool) { + $this->sessionPool->release($this->session); + } else { + $this->session->delete(); + } + + $this->session = null; + } } /** - * Retrieve a session from the session pool. + * Closes the database connection. + */ + public function __destruct() + { + try { + $this->close(); + } catch (\Exception $ex) { + } + } + + /** + * Create a new session. + * + * Sessions are handled behind the scenes and this method not need to be + * called directly. * - * @param string $context The session context. + * @access private + * @param array $options [optional] Configuration options. * @return Session */ - private function selectSession($context = SessionPoolInterface::CONTEXT_READ) + public function createSession(array $options = []) { - return $this->sessionPool->session( - $this->instance->name(), - $this->name, - $context - ); + $res = $this->connection->createSession($options + [ + 'database' => GrpcSpannerClient::formatDatabaseName( + $this->projectId, + $this->instance->name(), + $this->name + ) + ]); + + return $this->session($res['name']); } /** - * Convert the simple database name to a fully qualified name. + * Lazily instantiates a session. There are no network requests made at this + * point. To see the operations that can be performed on a session please + * see {@see Google\Cloud\Spanner\Session\Session}. * - * @return string + * Sessions are handled behind the scenes and this method not need to be + * called directly. + * + * @access private + * @param string $name The session's name. + * @return Session */ - private function fullyQualifiedDatabaseName() + public function session($name) { - return GrpcSpannerClient::formatDatabaseName( + return new Session( + $this->connection, $this->projectId, - $this->instance->name(), - $this->name + GrpcSpannerClient::parseInstanceFromSessionName($name), + GrpcSpannerClient::parseDatabaseFromSessionName($name), + GrpcSpannerClient::parseSessionFromSessionName($name) ); } + /** + * Retrieves the database's identity. + * + * @access private + * @return array + */ + public function identity() + { + return [ + 'database' => $this->name, + 'instance' => $this->instance->name(), + ]; + } + /** * Represent the class in a more readable and digestable fashion. * @@ -1119,4 +1274,40 @@ public function __debugInfo() 'sessionPool' => $this->sessionPool, ]; } + + /** + * If no session is already associated with the database use the session + * pool implementation to retrieve a session one - otherwise create on + * demand. + * + * @param string $context [optional] The session context. **Defaults to** + * `r` (READ). + * @return Session + */ + private function selectSession($context = SessionPoolInterface::CONTEXT_READ) + { + if ($this->session) { + return $this->session; + } + + if ($this->sessionPool) { + return $this->session = $this->sessionPool->acquire($context); + } else { + return $this->session = $this->createSession(); + } + } + + /** + * Convert the simple database name to a fully qualified name. + * + * @return string + */ + private function fullyQualifiedDatabaseName() + { + return GrpcSpannerClient::formatDatabaseName( + $this->projectId, + $this->instance->name(), + $this->name + ); + } } diff --git a/src/Spanner/Duration.php b/src/Spanner/Duration.php index c867d55fe99d..4fe506ac0880 100644 --- a/src/Spanner/Duration.php +++ b/src/Spanner/Duration.php @@ -24,7 +24,7 @@ * ``` * use Google\Cloud\Spanner\SpannerClient; * - * $spanner = new SpannerClient; + * $spanner = new SpannerClient(); * * $seconds = 100; * $nanoSeconds = 000001; diff --git a/src/Spanner/Instance.php b/src/Spanner/Instance.php index 44e029983010..30746a269879 100644 --- a/src/Spanner/Instance.php +++ b/src/Spanner/Instance.php @@ -37,10 +37,9 @@ * * Example: * ``` - * use Google\Cloud\ServiceBuilder; + * use Google\Cloud\Spanner\SpannerClient; * - * $cloud = new ServiceBuilder(); - * $spanner = $cloud->spanner(); + * $spanner = new SpannerClient(); * * $instance = $spanner->instance('my-instance'); * ``` @@ -58,11 +57,6 @@ class Instance */ private $connection; - /** - * @var SessionPool; - */ - private $sessionPool; - /** * @var LongRunningConnectionInterface */ @@ -103,20 +97,18 @@ class Instance * * @param ConnectionInterface $connection The connection to the * Google Cloud Spanner Admin API. - * @param SessionPoolInterface $sessionPool The session pool implementation. * @param LongRunningConnectionInterface $lroConnection An implementation * mapping to methods which handle LRO resolution in the service. * @param array $lroCallables * @param string $projectId The project ID. * @param string $name The instance name. - * @param bool $returnInt64AsObject If true, 64 bit integers will be + * @param bool $returnInt64AsObject [optional] If true, 64 bit integers will be * returned as a {@see Google\Cloud\Core\Int64} object for 32 bit platform * compatibility. **Defaults to** false. * @param array $info [optional] A representation of the instance object. */ public function __construct( ConnectionInterface $connection, - SessionPoolInterface $sessionPool, LongRunningConnectionInterface $lroConnection, array $lroCallables, $projectId, @@ -125,7 +117,6 @@ public function __construct( array $info = [] ) { $this->connection = $connection; - $this->sessionPool = $sessionPool; $this->lroConnection = $lroConnection; $this->lroCallables = $lroCallables; $this->projectId = $projectId; @@ -363,18 +354,24 @@ public function createDatabase($name, array $options = []) * ``` * * @param string $name The database name + * @param array $options [optional] { + * Configuration options. + * + * @type SessionPoolInterface $sessionPool A pool used to manage + * sessions. + * } * @return Database */ - public function database($name) + public function database($name, array $options = []) { return new Database( $this->connection, $this, - $this->sessionPool, $this->lroConnection, $this->lroCallables, $this->projectId, $name, + isset($options['sessionPool']) ? $options['sessionPool'] : null, $this->returnInt64AsObject ); } diff --git a/src/Spanner/KeyRange.php b/src/Spanner/KeyRange.php index ecd45a1f9529..db5a432ba029 100644 --- a/src/Spanner/KeyRange.php +++ b/src/Spanner/KeyRange.php @@ -22,10 +22,9 @@ * * Example: * ``` - * use Google\Cloud\ServiceBuilder; + * use Google\Cloud\Spanner\SpannerClient; * - * $cloud = new ServiceBuilder(); - * $spanner = $cloud->spanner(); + * $spanner = new SpannerClient(); * * // Create a KeyRange for all people named Bob, born in 1969. * $start = $spanner->date(new \DateTime('1969-01-01')); diff --git a/src/Spanner/Operation.php b/src/Spanner/Operation.php index b242972b5ea1..e9bddbf9a88e 100644 --- a/src/Spanner/Operation.php +++ b/src/Spanner/Operation.php @@ -134,7 +134,7 @@ public function commit(Session $session, array $mutations, array $options = []) } /** - * Rollback a Transaction + * Rollback a Transaction. * * @param Session $session The session to use for the rollback. * Note that the session MUST be the same one in which the @@ -152,12 +152,12 @@ public function rollback(Session $session, $transactionId, array $options = []) } /** - * Run a query + * Run a query. * * @param Session $session The session to use to execute the SQL. * @param string $sql The query string. * @param array $options [optional] Configuration options. - * @return array + * @return Result */ public function execute(Session $session, $sql, array $options = []) { @@ -171,12 +171,18 @@ public function execute(Session $session, $sql, array $options = []) $context = $this->pluck('transactionContext', $options); - $res = $this->connection->executeSql([ - 'sql' => $sql, - 'session' => $session->name() - ] + $options); + $call = function ($resumeToken = null) use ($session, $sql, $options) { + if ($resumeToken) { + $options['resumeToken'] = $resumeToken; + } + + return $this->connection->executeStreamingSql([ + 'sql' => $sql, + 'session' => $session->name() + ] + $options); + }; - return $this->createResult($session, $res, $context); + return new Result($this, $session, $call, $context, $this->mapper); } /** @@ -205,22 +211,26 @@ public function read(Session $session, $table, KeySet $keySet, array $columns, a ]; $context = $this->pluck('transactionContext', $options); - $res = $this->connection->read([ - 'table' => $table, - 'session' => $session->name(), - 'columns' => $columns, - 'keySet' => $this->flattenKeySet($keySet) - ] + $options); - return $this->createResult($session, $res, $context); + $call = function ($resumeToken = null) use ($table, $session, $columns, $keySet, $options) { + if ($resumeToken) { + $options['resumeToken'] = $resumeToken; + } + + return $this->connection->streamingRead([ + 'table' => $table, + 'session' => $session->name(), + 'columns' => $columns, + 'keySet' => $this->flattenKeySet($keySet) + ] + $options); + }; + + return new Result($this, $session, $call, $context, $this->mapper); } /** * Create a read/write transaction. * - * @todo if a transaction is already available on the session, get it instead - * of starting a new one? - * * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest * * @param Session $session The session to start the transaction in. @@ -249,26 +259,6 @@ public function snapshot(Session $session, array $options = []) return $this->createSnapshot($session, $res); } - /** - * Execute a service call to begin a transaction or snapshot. - * - * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest - * - * @param Session $session The session to start the snapshot in. - * @param array $options [optional] Configuration options. - * @return array - */ - private function beginTransaction(Session $session, array $options = []) - { - $options += [ - 'transactionOptions' => [] - ]; - - return $this->connection->beginTransaction($options + [ - 'session' => $session->name(), - ]); - } - /** * Create a Transaction instance from a response object. * @@ -276,7 +266,7 @@ private function beginTransaction(Session $session, array $options = []) * @param array $res The transaction response. * @return Transaction */ - private function createTransaction(Session $session, array $res) + public function createTransaction(Session $session, array $res) { return new Transaction($this, $session, $res['id']); } @@ -288,7 +278,7 @@ private function createTransaction(Session $session, array $res) * @param array $res The snapshot response. * @return Snapshot */ - private function createSnapshot(Session $session, array $res) + public function createSnapshot(Session $session, array $res) { $timestamp = null; if (isset($res['readTimestamp'])) { @@ -299,38 +289,23 @@ private function createSnapshot(Session $session, array $res) } /** - * Transform a service read or executeSql response to a friendly result. + * Execute a service call to begin a transaction or snapshot. * - * @codingStandardsIgnoreStart - * @param Session $session The current session. - * @param array $res [ResultSet](https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.ResultSet) - * @param string $transactionContext - * @return Result - * @codingStandardsIgnoreEnd + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest + * + * @param Session $session The session to start the snapshot in. + * @param array $options [optional] Configuration options. + * @return array */ - private function createResult(Session $session, array $res, $transactionContext) + private function beginTransaction(Session $session, array $options = []) { - $columns = isset($res['metadata']['rowType']['fields']) - ? $res['metadata']['rowType']['fields'] - : []; - - $rows = []; - if (isset($res['rows'])) { - foreach ($res['rows'] as $row) { - $rows[] = $this->mapper->decodeValues($columns, $row); - } - } - - $options = []; - if (isset($res['metadata']['transaction']['id'])) { - if ($transactionContext === SessionPoolInterface::CONTEXT_READ) { - $options['snapshot'] = $this->createSnapshot($session, $res['metadata']['transaction']); - } else { - $options['transaction'] = $this->createTransaction($session, $res['metadata']['transaction']); - } - } + $options += [ + 'transactionOptions' => [] + ]; - return new Result($res, $rows, $options); + return $this->connection->beginTransaction($options + [ + 'session' => $session->name(), + ]); } /** diff --git a/src/Spanner/Result.php b/src/Spanner/Result.php index c168438ce963..40b1d09309f7 100644 --- a/src/Spanner/Result.php +++ b/src/Spanner/Result.php @@ -17,17 +17,20 @@ namespace Google\Cloud\Spanner; +use Google\Cloud\Core\Exception\ServiceException; +use Google\Cloud\Spanner\Session\Session; +use Google\Cloud\Spanner\Session\SessionPoolInterface; + /** * Represent a Google Cloud Spanner lookup result (either read or executeSql). * * Example: * ``` - * use Google\Cloud\ServiceBuilder; + * use Google\Cloud\Spanner\SpannerClient; * - * $cloud = new ServiceBuilder(); - * $spanner = $cloud->spanner(); - * $database = $spanner->connect('my-instance', 'my-database'); + * $spanner = new SpannerClient(); * + * $database = $spanner->connect('my-instance', 'my-database'); * $result = $database->execute('SELECT * FROM Posts'); * ``` * @@ -36,88 +39,138 @@ class Result implements \IteratorAggregate { /** - * @var array + * @var array|null */ - private $result; + private $cachedValues; /** * @var array */ - private $rows; + private $columns = []; /** - * @var array + * @var ValueMapper */ - private $options; + private $mapper; /** - * @param array $result The query or read result. - * @param array $rows The rows, formatted and decoded. - * @param array $options Additional result options and info. + * @var array|null */ - public function __construct(array $result, array $rows, array $options = []) - { - $this->result = $result; - $this->rows = $rows; - $this->options = $options; - } + private $metadata; /** - * Return result metadata - * - * Example: - * ``` - * $metadata = $result->metadata(); - * ``` - * - * @codingStandardsIgnoreStart - * @return array [ResultSetMetadata](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.ResultSetMetadata). - * @codingStandardsIgnoreEnd + * @var Operation */ - public function metadata() - { - return $this->result['metadata']; + private $operation; + + /** + * @var string|null + */ + private $resumeToken; + + /** + * @var Session + */ + private $session; + + /** + * @var Snapshot|null + */ + private $snapshot; + + /** + * @var array|null + */ + private $stats; + + /** + * @var Transaction|null + */ + private $transaction; + + /** + * @var string + */ + private $transactionContext; + + /** + * @param Operation $operation Runs operations against Google Cloud Spanner. + * @param Session $session The session used for any operations executed. + * @param \Generator $resultGenerator Reads rows from Google Cloud Spanner. + * @param string $transactionContext The transaction's context. + * @param ValueMapper $mapper Maps values. + */ + public function __construct( + Operation $operation, + Session $session, + callable $call, + $transactionContext, + ValueMapper $mapper + ) { + $this->operation = $operation; + $this->session = $session; + $this->call = $call; + $this->transactionContext = $transactionContext; + $this->mapper = $mapper; } /** * Return the formatted and decoded rows. * + * If the stream is interrupted an attempt will be made to resume. + * * Example: * ``` * $rows = $result->rows(); * ``` * - * @return array|null + * @return \Generator */ public function rows() { - return $this->rows; + $call = $this->call; + + try { + foreach ($this->getRows($call()) as $row) { + yield $row; + } + } catch (ServiceException $ex) { + if (!$this->resumeToken) { + throw $ex; + } + + // If we have a token, attempt to resume + foreach ($this->getRows($call($this->resumeToken)) as $row) { + yield $row; + } + } } /** - * Return the first row, or null. + * Return result metadata. * - * Useful when selecting a single row. + * Will be populated once the result set is iterated upon. * * Example: * ``` - * $row = $result->firstRow(); + * $metadata = $result->metadata(); * ``` * - * @return array|null + * @codingStandardsIgnoreStart + * @return array|null [ResultSetMetadata](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.ResultSetMetadata). + * @codingStandardsIgnoreEnd */ - public function firstRow() + public function metadata() { - return (isset($this->rows[0])) - ? $this->rows[0] - : null; + return $this->metadata; } /** * Get the query plan and execution statistics for the query that produced * this result set. * - * Stats are not returned by default. + * Stats are not returned by default and will not be accessible until the + * entire set of results has been iterated through. * * Example: * ``` @@ -137,67 +190,137 @@ public function firstRow() */ public function stats() { - return (isset($this->result['stats'])) - ? $this->result['stats'] - : null; + return $this->stats; } /** - * Returns a transaction which was begun in the read or execute, if one exists. + * Returns a snapshot which was begun in the read or execute, if one exists. + * + * Will be populated once the result set is iterated upon. * * Example: * ``` - * $transaction = $result->transaction(); + * $snapshot = $result->snapshot(); * ``` * - * @return Transaction|null + * @return Snapshot|null */ - public function transaction() + public function snapshot() { - return (isset($this->options['transaction'])) - ? $this->options['transaction'] - : null; + return $this->snapshot; } /** - * Returns a snapshot which was begun in the read or execute, if one exists. + * Returns a transaction which was begun in the read or execute, if one exists. + * + * Will be populated once the result set is iterated upon. * * Example: * ``` - * $snapshot = $result->snapshot(); + * $transaction = $result->transaction(); * ``` * - * @return Snapshot|null + * @return Transaction|null */ - public function snapshot() + public function transaction() { - return (isset($this->options['snapshot'])) - ? $this->options['snapshot'] - : null; + return $this->transaction; } /** - * Get the entire query or read response as given by the API. + * @access private + */ + public function getIterator() + { + return $this->rows(); + } + + /** + * Yields rows from a partial result set. * - * Example: - * ``` - * $info = $result->info(); - * ``` + * @return \Generator + */ + private function getRowsFromPartial(array $partial) + { + $this->stats = isset($partial['stats']) ? $partial['stats'] : null; + $this->resumeToken = isset($partial['resumeToken']) ? $partial['resumeToken'] : null; + + if (isset($partial['metadata'])) { + $this->metadata = $partial['metadata']; + $this->columns = $partial['metadata']['rowType']['fields']; + } + + if (isset($partial['metadata']['transaction']['id'])) { + if ($this->transactionContext === SessionPoolInterface::CONTEXT_READ) { + $this->snapshot = $this->operation->createSnapshot( + $this->session, + $partial['metadata']['transaction'] + ); + } else { + $this->transaction = $this->operation->createTransaction( + $this->session, + $partial['metadata']['transaction'] + ); + } + } + + if ($this->cachedValues) { + $partial['values'] = $this->mergeValues($this->cachedValues, $partial['values']); + $this->cachedValues = null; + } + + if (isset($partial['chunkedValue'])) { + $this->cachedValues = $partial['values']; + return; + } + + $rows = []; + $columnCount = count($this->columns); + + if ($columnCount > 0 && isset($partial['values'])) { + $rows = array_chunk($partial['values'], $columnCount); + } + + foreach ($rows as $row) { + yield $this->mapper->decodeValues($this->columns, $row); + } + } + + /** + * Merge result set values together. * - * @codingStandardsIgnoreStart - * @return array [ResultSet](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.ResultSet). - * @codingStandardsIgnoreEnd + * @param array $cached + * @param array $new + * @return mixed */ - public function info() + private function mergeValues(array $cached, array $new) { - return $this->result; + $lastCachedItem = array_pop($cached); + $firstNewItem = array_shift($new); + $item = $firstNewItem; + + if (is_string($lastCachedItem) && is_string($firstNewItem)) { + $item = $lastCachedItem . $firstNewItem; + } elseif (is_array($lastCachedItem)) { + $item = $this->mergeValues($lastCachedItem, $firstNewItem); + } else { + array_push($cached, $lastCachedItem); + } + + array_push($cached, $item); + return array_merge($cached, $new); } /** - * @access private + * @param \Generator $results + * @return \Generator */ - public function getIterator() + private function getRows(\Generator $results) { - return new \ArrayIterator($this->rows); + foreach ($results as $partial) { + foreach ($this->getRowsFromPartial($partial) as $row) { + yield $row; + } + } } } diff --git a/src/Spanner/Session/CacheSessionPool.php b/src/Spanner/Session/CacheSessionPool.php new file mode 100644 index 000000000000..da881bc7860b --- /dev/null +++ b/src/Spanner/Session/CacheSessionPool.php @@ -0,0 +1,547 @@ +connect('my-instance', 'my-database', [ + * 'sessionPool' => $sessionPool + * ]); + * ``` + */ +class CacheSessionPool implements SessionPoolInterface +{ + const CACHE_KEY_TEMPLATE = 'cache-session-pool.%s.%s'; + + /** + * @var array + */ + private static $defaultConfig = [ + 'maxSessions' => PHP_INT_MAX, + 'minSessions' => 1, + 'shouldWaitForSession' => true, + 'maxCyclesToWaitForSession' => 30, + 'sleepIntervalSeconds' => .5 + ]; + + /** + * @var CacheItemPoolInterface + */ + private $cacheItemPool; + + /** + * @var string + */ + private $cacheKey; + + /** + * @var array + */ + private $config; + + /** + * @var Database + */ + private $database; + + /** + * @param CacheItemPoolInterface $cacheItemPool A PSR-6 compatible cache + * implementation used to store the session data. + * @param array $config [optional] { + * Configuration Options. + * + * @type int $maxSessions The maximum number of sessions to store in the + * pool. **Defaults to** PHP_INT_MAX. + * @type int $minSessions The minimum number of sessions to store in the + * pool. **Defaults to** `1`. + * @type bool $shouldWaitForSession If the pool is full, whether to block + * until a new session is available. **Defaults to* `true`. + * @type int $maxCyclesToWaitForSession The maximum number cycles to + * wait for a session before throwing an exception. **Defaults to** + * `30`. Ignored when $shouldWaitForSession is `false`. + * @type float $sleepIntervalSeconds The sleep interval between cycles. + * **Defaults to** `0.5`. Ignored when $shouldWaitForSession is + * `false`. + * @type LockInterface $lock A lock implementation capable of blocking. + * **Defaults to** an flock based implementation. + * } + * @throws \InvalidArgumentException + */ + public function __construct(CacheItemPoolInterface $cacheItemPool, array $config = []) + { + $this->cacheItemPool = $cacheItemPool; + $this->config = $config + self::$defaultConfig; + + if (!isset($this->config['lock'])) { + $this->config['lock'] = $this->getDefaultLock(); + } + + $this->validateConfig(); + } + + /** + * Acquire a session from the pool. + * + * @param string $context The type of session to fetch. May be either `r` + * (READ) or `rw` (READ/WRITE). **Defaults to** `r`. + * @return Session + * @throws \RuntimeException + */ + public function acquire($context = SessionPoolInterface::CONTEXT_READ) + { + // Try to get a session, run maintenance on the pool, and calculate if + // we need to create any new sessions. + list($session, $toCreate) = $this->config['lock']->synchronize(function () { + $toCreate = []; + $session = null; + $shouldSave = false; + $item = $this->cacheItemPool->getItem($this->cacheKey); + $data = (array) $item->get() ?: $this->initialize(); + + // If the queue has items in it, let's shift one off, however if the + // queue is empty and we have maxed out the number of sessions let's + // attempt to purge any orphaned items from the pool to make room + // for more. + if ($data['queue']) { + $session = $this->getSession($data); + $shouldSave = true; + } elseif ($this->config['maxSessions'] <= $this->getSessionCount($data)) { + $this->purgeOrphanedInUseSessions($data); + $this->purgeOrphanedToCreateItems($data); + $shouldSave = true; + } + + $toCreate = $this->buildToCreateList($data, is_array($session)); + $data['toCreate'] += $toCreate; + + if ($shouldSave || $toCreate) { + $this->cacheItemPool->save($item->set($data)); + } + + return [$session, $toCreate]; + }); + + // Create sessions if needed. + if ($toCreate) { + $createdSessions = []; + $exception = null; + + try { + $createdSessions = $this->createSessions(count($toCreate)); + } catch (\Exception $ex) { + $exception = $ex; + } + + $session = $this->config['lock']->synchronize(function () use ( + $session, + $toCreate, + $createdSessions, + $exception + ) { + $item = $this->cacheItemPool->getItem($this->cacheKey); + $data = $item->get(); + $data['queue'] = array_merge($data['queue'], $createdSessions); + + // Now that we've created the sessions, we can remove them from + // the list of intent. + foreach ($toCreate as $id => $time) { + unset($data['toCreate'][$id]); + } + + if (!$session && !$exception) { + $session = array_shift($data['queue']); + + $data['inUse'][$session['name']] = $session + [ + 'lastActive' => $this->time() + ]; + } + + $this->cacheItemPool->save($item->set($data)); + + return $session; + }); + + if ($exception) { + throw $exception; + } + } + + if ($session) { + $session = $this->handleSession($session); + } + + // If we don't have a session, let's wait for one or throw an exception. + if (!$session) { + if (!$this->config['shouldWaitForSession']) { + throw new \RuntimeException('No sessions available.'); + } + + $session = $this->waitForNextAvailableSession(); + } + + return $this->database->session($session['name']); + } + + /** + * Release a session back to the pool. + * + * @param Session $session The session. + */ + public function release(Session $session) + { + $this->config['lock']->synchronize(function () use ($session) { + $item = $this->cacheItemPool->getItem($this->cacheKey); + $data = $item->get(); + + unset($data['inUse'][$session->name()]); + array_push($data['queue'], [ + 'name' => $session->name(), + 'expiration' => $session->expiration() ?: $this->time() + SessionPoolInterface::SESSION_EXPIRATION_SECONDS + ]); + $this->cacheItemPool->save($item->set($data)); + }); + } + + /** + * Clear the session pool. + */ + public function clear() + { + $this->cacheItemPool->clear(); + } + + /** + * Set the database used to make calls to manage sessions. + * + * @param Database $database The database. + */ + public function setDatabase(Database $database) + { + $this->database = $database; + $identity = $database->identity(); + $this->cacheKey = sprintf(self::CACHE_KEY_TEMPLATE, $identity['instance'], $identity['database']); + } + + /** + * Get the underlying cache implementation. + * + * @return CacheItemPoolInterface + */ + public function cacheItemPool() + { + return $this->cacheItemPool; + } + + /** + * Get the current unix timestamp. + * + * @return int + */ + protected function time() + { + return time(); + } + + /** + * Builds out a list of timestamps indicating the start time of the intent + * to create a session. + * + * @param array $data + * @param bool $hasSession + * @return array + */ + private function buildToCreateList(array $data, $hasSession) + { + $number = 0; + $toCreate = []; + $time = $this->time(); + $count = $this->getSessionCount($data); + + if ($count < $this->config['minSessions']) { + $number = $this->config['minSessions'] - $count; + } elseif (!$hasSession && !$data['queue'] && $count < $this->config['maxSessions']) { + $number++; + } + + for ($i = 0; $i < $number; $i++) { + $toCreate[uniqid($time . '_')] = $time; + } + + return $toCreate; + } + + /** + * Purge any items in the to create queue that have been inactive for 20 + * minutes or more. + * + * @param array $data + */ + private function purgeOrphanedToCreateItems(array &$data) + { + foreach ($data['toCreate'] as $key => $timestamp) { + if ($timestamp + 1200 < $this->time()) { + unset($data['toCreate'][$key]); + } + } + } + + /** + * Purge any in use sessions that have been inactive for 20 minutes or more. + * + * @param array $data + */ + private function purgeOrphanedInUseSessions(array &$data) + { + foreach ($data['inUse'] as $key => $session) { + if ($session['lastActive'] + 1200 < $this->time()) { + unset($data['inUse'][$key]); + } + } + } + + /** + * Initialize the session data. + * + * @return array + */ + private function initialize() + { + return [ + 'queue' => [], + 'inUse' => [], + 'toCreate' => [] + ]; + } + + /** + * Returns the total count of sessions in queue, use, and in the process of + * being created. + * + * @param array $data + * @return int + */ + private function getSessionCount(array $data) + { + $count = 0; + + foreach ($data as $sessionType) { + $count += count($sessionType); + } + + return $count; + } + + /** + * Gets the next session in the queue, clearing out which are expired. + * + * @param array $data + * @return array|null + */ + private function getSession(array &$data) + { + $session = array_shift($data['queue']); + + if ($session) { + if ($session['expiration'] - 60 < $this->time()) { + return $this->getSession($data); + } + + $data['inUse'][$session['name']] = $session + [ + 'lastActive' => $this->time() + ]; + } + + return $session; + } + + /** + * Creates sessions up to the count provided. + * + * @param int $count + * @return array + */ + private function createSessions($count) + { + $sessions = []; + + for ($i = 0; $i < $count; $i++) { + $sessions[] = [ + 'name' => $this->database->createSession()->name(), + 'expiration' => $this->time() + SessionPoolInterface::SESSION_EXPIRATION_SECONDS + ]; + } + + return $sessions; + } + + /** + * If necessary, triggers a network request to determine the status of the + * provided session. + * + * @param array $session + * @return bool + */ + private function isSessionValid(array $session) + { + $halfHourBeforeExpiration = $session['expiration'] - (SessionPoolInterface::SESSION_EXPIRATION_SECONDS / 2); + + if ($this->time() < $halfHourBeforeExpiration) { + return true; + } elseif ($halfHourBeforeExpiration < $this->time() && $this->time() < $session['expiration']) { + return $this->database + ->session($session['name']) + ->exists(); + } + + return false; + } + + /** + * If the session is valid, return it - otherwise remove from the in use + * list. + * + * @param array $session + * @return array|null + */ + private function handleSession(array $session) + { + if ($this->isSessionValid($session)) { + return $session; + } + + $this->config['lock']->synchronize(function () use ($session) { + $item = $this->cacheItemPool->getItem($this->cacheKey); + $data = $item->get(); + unset($data['inUse'][$session['name']]); + $this->cacheItemPool->save($item->set($data)); + }); + } + + /** + * Blocks until a session becomes available. + * + * @return array + * @throws \RuntimeException + */ + private function waitForNextAvailableSession() + { + $elapsedCycles = 0; + + while (true) { + $session = $this->config['lock']->synchronize(function () use ($elapsedCycles) { + $item = $this->cacheItemPool->getItem($this->cacheKey); + $data = $item->get(); + $session = $this->getSession($data); + + if ($session) { + $this->cacheItemPool->save($item->set($data)); + return $session; + } + + if ($this->config['maxCyclesToWaitForSession'] <= $elapsedCycles) { + $this->cacheItemPool->save($item->set($data)); + + throw new \RuntimeException( + 'A session did not become available in the allotted number of attempts.' + ); + } + }); + + if ($session && $this->handleSession($session)) { + return $session; + } + + $elapsedCycles++; + usleep($this->config['sleepIntervalSeconds'] * 1000000); + } + } + + /** + * Get the default lock. + * + * @return LockInterface + */ + private function getDefaultLock() + { + $store = new FlockStore(sys_get_temp_dir()); + + return new SymfonyLockAdapter( + (new Factory($store))->createLock($this->cacheKey) + ); + } + + /** + * Validate the config. + * + * @param array $config + * @throws \InvalidArgumentException + */ + private function validateConfig() + { + $mustBePositiveKeys = ['maxCyclesToWaitForSession', 'maxSessions', 'minSessions', 'sleepIntervalSeconds']; + + foreach ($mustBePositiveKeys as $key) { + if ($this->config[$key] < 0) { + throw new \InvalidArgumentException("$key may not be negative"); + } + } + + if ($this->config['maxSessions'] < $this->config['minSessions']) { + throw new \InvalidArgumentException('minSessions cannot exceed maxSessions'); + } + + if (!$this->config['lock'] instanceof LockInterface) { + throw new \InvalidArgumentException( + 'The lock must implement Google\Cloud\Core\Lock\LockInterface' + ); + } + } +} diff --git a/src/Spanner/Session/Session.php b/src/Spanner/Session/Session.php index 0ecf8f3d3a00..c1ae06042f4a 100644 --- a/src/Spanner/Session/Session.php +++ b/src/Spanner/Session/Session.php @@ -51,6 +51,11 @@ class Session */ private $name; + /** + * @var int|null + */ + private $expiration; + /** * @param ConnectionInterface $connection A connection to Cloud Spanner. * @param string $projectId The project ID. @@ -58,8 +63,13 @@ class Session * @param string $database The database name. * @param string $name The session name. */ - public function __construct(ConnectionInterface $connection, $projectId, $instance, $database, $name) - { + public function __construct( + ConnectionInterface $connection, + $projectId, + $instance, + $database, + $name + ) { $this->connection = $connection; $this->projectId = $projectId; $this->instance = $instance; @@ -68,7 +78,7 @@ public function __construct(ConnectionInterface $connection, $projectId, $instan } /** - * Return info on the session + * Return info on the session. * * @return array An array containing the `projectId`, `instance`, `database` and session `name` keys. */ @@ -130,7 +140,33 @@ public function name() } /** + * Sets the expiration. + * + * @param int $expiration [optional] The Unix timestamp in seconds upon + * which the session will expire. **Defaults to** now plus 60 + * minutes. + * @return int + */ + public function setExpiration($expiration = null) + { + $this->expiration = $expiration ?: time() + SessionPoolInterface::SESSION_EXPIRATION_SECONDS; + } + + /** + * Gets the expiration. + * + * @return int|null + */ + public function expiration() + { + return $this->expiration; + } + + /** + * Represent the class in a more readable and digestable fashion. + * * @access private + * @codeCoverageIgnore */ public function __debugInfo() { diff --git a/src/Spanner/Session/SessionClient.php b/src/Spanner/Session/SessionClient.php deleted file mode 100644 index 1642e9519c3a..000000000000 --- a/src/Spanner/Session/SessionClient.php +++ /dev/null @@ -1,104 +0,0 @@ -connection = $connection; - $this->projectId = $projectId; - } - - /** - * Create a new session in the given instance and database. - * - * @param string $instance The simple instance name. - * @param string $database The simple database name. - * @param array $options [optional] Configuration options. - * @return Session|null If the operation succeeded, a Session object will be returned, - * otherwise null. - */ - public function create($instance, $database, array $options = []) - { - $res = $this->connection->createSession($options + [ - 'database' => SpannerClient::formatDatabaseName($this->projectId, $instance, $database) - ]); - - $session = null; - if (isset($res['name'])) { - $session = $this->session($res['name']); - } - - return $session; - } - - /** - * Get a Session - * - * @param string $sessionName The Session name. - * @return Session - */ - public function session($sessionName) - { - return new Session( - $this->connection, - $this->projectId, - SpannerClient::parseInstanceFromSessionName($sessionName), - SpannerClient::parseDatabaseFromSessionName($sessionName), - SpannerClient::parseSessionFromSessionName($sessionName) - ); - } - - /** - * @access private - */ - public function __debugInfo() - { - return [ - 'connection' => get_class($this->connection), - 'projectId' => $this->projectId - ]; - } -} diff --git a/src/Spanner/Session/SessionPool.php b/src/Spanner/Session/SessionPool.php deleted file mode 100644 index 2c96d8b12e88..000000000000 --- a/src/Spanner/Session/SessionPool.php +++ /dev/null @@ -1,63 +0,0 @@ -sessionClient = $sessionClient; - } - - /** - * @access private - */ - public function addSession(Session $session) - { - $this->sessions[] = $sessions; - } - - /** - * @access private - */ - public function session($instance, $database, $context, array $options = []) - { - return array_rand($this->sessions); - } - - /** - * @access private - */ - public function refreshSessions() - { - // send a request from each session to keep it alive. - } -} diff --git a/src/Spanner/Session/SessionPoolInterface.php b/src/Spanner/Session/SessionPoolInterface.php index 2aa4943d981c..5ef776a9bc32 100644 --- a/src/Spanner/Session/SessionPoolInterface.php +++ b/src/Spanner/Session/SessionPoolInterface.php @@ -17,6 +17,8 @@ namespace Google\Cloud\Spanner\Session; +use Google\Cloud\Spanner\Database; + /** * Describes a session pool. */ @@ -24,9 +26,34 @@ interface SessionPoolInterface { const CONTEXT_READ = 'r'; const CONTEXT_READWRITE = 'rw'; + const SESSION_EXPIRATION_SECONDS = 3600; + + /** + * Acquire a session from the pool. + * + * @param string $context The type of session to fetch. May be either `r` + * (READ) or `rw` (READ/WRITE). **Defaults to** `r`. + * @return Session + * @throws \RunTimeException + */ + public function acquire($context); + + /** + * Release a session back to the pool. + * + * @param Session $session + */ + public function release(Session $session); + + /** + * Clear the session pool. + */ + public function clear(); /** - * Get a session from the pool + * Set the database used to make calls to manage sessions. + * + * @param Database $database */ - public function session($instance, $database, $context, array $options = []); + public function setDatabase(Database $database); } diff --git a/src/Spanner/Session/SimpleSessionPool.php b/src/Spanner/Session/SimpleSessionPool.php deleted file mode 100644 index d1e756f7b9cf..000000000000 --- a/src/Spanner/Session/SimpleSessionPool.php +++ /dev/null @@ -1,54 +0,0 @@ -sessionClient = $sessionClient; - } - - /** - * @access private - */ - public function session($instance, $database, $mode, array $options = []) - { - if (!isset($this->sessions[$instance.$database.$mode])) { - $this->sessions[$instance.$database] = $this->sessionClient->create($instance, $database, $options); - } - - return $this->sessions[$instance.$database]; - } -} diff --git a/src/Spanner/Snapshot.php b/src/Spanner/Snapshot.php index 09fd1e2e5af3..8655e7d5349e 100644 --- a/src/Spanner/Snapshot.php +++ b/src/Spanner/Snapshot.php @@ -49,6 +49,10 @@ * ] * ] * ); + * + * $firstRow = $result + * ->rows() + * ->current(); * ``` * * @param string $sql The query string to execute. @@ -73,7 +77,10 @@ * $columns = ['ID', 'title', 'content']; * * $result = $snapshot->read('Posts', $keySet, $columns); - * ``` + * + * $firstRow = $result + * ->rows() + * ->current(); * * @param string $table The table name. * @param KeySet $keySet The KeySet to select rows. diff --git a/src/Spanner/SpannerClient.php b/src/Spanner/SpannerClient.php index 3c4034bfac67..c28c4ef0db68 100644 --- a/src/Spanner/SpannerClient.php +++ b/src/Spanner/SpannerClient.php @@ -30,8 +30,6 @@ use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; use Google\Cloud\Spanner\Connection\Grpc; use Google\Cloud\Spanner\Connection\LongRunningConnection; -use Google\Cloud\Spanner\Session\SessionClient; -use Google\Cloud\Spanner\Session\SimpleSessionPool; use google\spanner\admin\instance\v1\Instance\State; use Psr\Http\StreamInterface; @@ -70,16 +68,6 @@ class SpannerClient */ private $lroConnection; - /** - * @var SessionClient - */ - protected $sessionClient; - - /** - * @var SessionPool - */ - protected $sessionPool; - /** * @var bool */ @@ -109,7 +97,7 @@ class SpannerClient * returned as a {@see Google\Cloud\Core\Int64} object for 32 bit * platform compatibility. **Defaults to** false. * } - * @throws Google\Cloud\Exception\GoogleException + * @throws Google\Cloud\Core\Exception\GoogleException */ public function __construct(array $config = []) { @@ -123,12 +111,7 @@ public function __construct(array $config = []) $this->connection = new Grpc($this->configureAuthentication($config)); $this->lroConnection = new LongRunningConnection($this->connection); - - $this->sessionClient = new SessionClient($this->connection, $this->projectId); - $this->sessionPool = new SimpleSessionPool($this->sessionClient); - $this->returnInt64AsObject = $config['returnInt64AsObject']; - $this->lroCallables = [ [ 'typeUrl' => 'type.googleapis.com/google.spanner.admin.instance.v1.UpdateInstanceMetadata', @@ -287,7 +270,6 @@ public function instance($name, array $instance = []) { return new Instance( $this->connection, - $this->sessionPool, $this->lroConnection, $this->lroCallables, $this->projectId, @@ -356,15 +338,21 @@ function (array $instance) { * * @param Instance|string $instance The instance object or instance name. * @param string $name The database name. + * @param array $options [optional] { + * Configuration options. + * + * @type SessionPoolInterface $sessionPool A pool used to manage + * sessions. + * } * @return Database */ - public function connect($instance, $name) + public function connect($instance, $name, array $options = []) { if (is_string($instance)) { $instance = $this->instance($instance); } - $database = $instance->database($name); + $database = $instance->database($name, $options); return $database; } @@ -517,21 +505,6 @@ public function duration($seconds, $nanos = 0) return new Duration($seconds, $nanos); } - /** - * Get the session client - * - * Example: - * ``` - * $sessionClient = $spanner->sessionClient(); - * ``` - * - * @return SessionClient - */ - public function sessionClient() - { - return $this->sessionClient; - } - /** * Resume a Long Running Operation * diff --git a/src/Spanner/Transaction.php b/src/Spanner/Transaction.php index 957bf91daf6e..e9c124324e38 100644 --- a/src/Spanner/Transaction.php +++ b/src/Spanner/Transaction.php @@ -44,10 +44,9 @@ * * Example: * ``` - * use Google\Cloud\ServiceBuilder; + * use Google\Cloud\Spanner\SpannerClient; * - * $cloud = new ServiceBuilder(); - * $spanner = $cloud->spanner(); + * $spanner = new SpannerClient(); * * $database = $spanner->connect('my-instance', 'my-database'); * @@ -76,6 +75,10 @@ * ] * ] * ); + * + * $firstRow = $result + * ->rows() + * ->current(); * ``` * * @param string $sql The query string to execute. @@ -100,6 +103,10 @@ * $columns = ['ID', 'title', 'content']; * * $result = $transaction->read('Posts', $keySet, $columns); + * + * $firstRow = $result + * ->rows() + * ->current(); * ``` * * @param string $table The table name. @@ -444,6 +451,11 @@ public function state() return $this->state; } + public function id() + { + return $this->transactionId; + } + /** * Format, validate and enqueue mutations in the transaction. * diff --git a/src/Spanner/ValueMapper.php b/src/Spanner/ValueMapper.php index e6c48ff90043..39c6620e07d0 100644 --- a/src/Spanner/ValueMapper.php +++ b/src/Spanner/ValueMapper.php @@ -97,11 +97,11 @@ public function encodeValuesAsSimpleType(array $values) * Accepts a list of columns (with name and type) and a row from read or * executeSql and decodes each value to its corresponding PHP type. * - * @param array $columns The list of columns + * @param array $columns The list of columns. * @param array $row The row data. * @return array The decoded row data. */ - public function decodeValues(array $columns, array $row, $extractResult = false) + public function decodeValues(array $columns, array $row) { $cols = []; $types = []; @@ -147,6 +147,10 @@ public function createTimestampWithNanos($timestamp) */ private function decodeValue($value, array $type) { + if ($value === null || $value === '') { + return $value; + } + switch ($type['code']) { case self::TYPE_INT64: $value = $this->returnInt64AsObject @@ -176,7 +180,7 @@ private function decodeValue($value, array $type) break; case self::TYPE_STRUCT: - $value = $this->decodeValues($type['structType']['fields'], $value, true); + $value = $this->decodeValues($type['structType']['fields'], $value); break; case self::TYPE_FLOAT64: diff --git a/tests/snippets/Spanner/DatabaseTest.php b/tests/snippets/Spanner/DatabaseTest.php index e7eb9b50b20b..36d85e57b480 100644 --- a/tests/snippets/Spanner/DatabaseTest.php +++ b/tests/snippets/Spanner/DatabaseTest.php @@ -55,18 +55,20 @@ public function setUp() $session = $this->prophesize(Session::class); $sessionPool = $this->prophesize(SessionPoolInterface::class); - $sessionPool->session(Argument::any(), Argument::any(), Argument::any()) + $sessionPool->acquire(Argument::any()) ->willReturn($session->reveal()); + $sessionPool->setDatabase(Argument::any()) + ->willReturn(null); $this->connection = $this->prophesize(ConnectionInterface::class); $this->database = \Google\Cloud\Dev\stub(Database::class, [ $this->connection->reveal(), $instance->reveal(), - $sessionPool->reveal(), $this->prophesize(LongRunningConnectionInterface::class)->reveal(), [], self::PROJECT, - self::DATABASE + self::DATABASE, + $sessionPool->reveal() ], ['connection', 'operation']); } @@ -246,9 +248,9 @@ public function testRunTransaction() $this->connection->rollback(Argument::any()) ->shouldNotBeCalled(); - $this->connection->executeSql(Argument::any()) + $this->connection->executeStreamingSql(Argument::any()) ->shouldBeCalled() - ->willReturn([ + ->willReturn($this->resultGenerator([ 'metadata' => [ 'rowType' => [ 'fields' => [ @@ -261,12 +263,8 @@ public function testRunTransaction() ] ] ], - 'rows' => [ - [ - 0 - ] - ] - ]); + 'values' => [0] + ])); $this->stubOperation(); @@ -293,14 +291,16 @@ public function testRunTransactionRollback() $this->connection->rollback(Argument::any()) ->shouldBeCalled(); - $this->connection->executeSql(Argument::any()) + $this->connection->executeStreamingSql(Argument::any()) ->shouldBeCalled() - ->willReturn([ + ->willReturn($this->resultGenerator([ 'metadata' => [ - 'rowType' => [] + 'rowType' => [ + 'fields' => [] + ] ], - 'rows' => [] - ]); + 'values' => [] + ])); $this->stubOperation(); @@ -485,9 +485,9 @@ public function testDelete() public function testExecute() { - $this->connection->executeSql(Argument::any()) + $this->connection->executeStreamingSql(Argument::any()) ->shouldBeCalled() - ->willReturn([ + ->willReturn($this->resultGenerator([ 'metadata' => [ 'rowType' => [ 'fields' => [ @@ -500,12 +500,8 @@ public function testExecute() ] ] ], - 'rows' => [ - [ - 0 - ] - ] - ]); + 'values' => [0] + ])); $this->stubOperation(); @@ -518,9 +514,9 @@ public function testExecute() public function testExecuteBeginSnapshot() { - $this->connection->executeSql(Argument::any()) + $this->connection->executeStreamingSql(Argument::any()) ->shouldBeCalled() - ->willReturn([ + ->willReturn($this->resultGenerator([ 'metadata' => [ 'rowType' => [ 'fields' => [ @@ -536,12 +532,8 @@ public function testExecuteBeginSnapshot() 'id' => self::TRANSACTION ] ], - 'rows' => [ - [ - 0 - ] - ] - ]); + 'values' => [0] + ])); $this->stubOperation(); @@ -555,9 +547,9 @@ public function testExecuteBeginSnapshot() public function testExecuteBeginTransaction() { - $this->connection->executeSql(Argument::any()) + $this->connection->executeStreamingSql(Argument::any()) ->shouldBeCalled() - ->willReturn([ + ->willReturn($this->resultGenerator([ 'metadata' => [ 'rowType' => [ 'fields' => [ @@ -573,12 +565,8 @@ public function testExecuteBeginTransaction() 'id' => self::TRANSACTION ] ], - 'rows' => [ - [ - 0 - ] - ] - ]); + 'values' => [0] + ])); $this->stubOperation(); @@ -593,9 +581,9 @@ public function testExecuteBeginTransaction() public function testRead() { - $this->connection->read(Argument::any()) + $this->connection->streamingRead(Argument::any()) ->shouldBeCalled() - ->willReturn([ + ->willReturn($this->resultGenerator([ 'metadata' => [ 'rowType' => [ 'fields' => [ @@ -608,12 +596,8 @@ public function testRead() ] ] ], - 'rows' => [ - [ - 0 - ] - ] - ]); + 'rows' => [0] + ])); $this->stubOperation(); @@ -627,9 +611,9 @@ public function testRead() public function testReadWithSnapshot() { - $this->connection->read(Argument::any()) + $this->connection->streamingRead(Argument::any()) ->shouldBeCalled() - ->willReturn([ + ->willReturn($this->resultGenerator([ 'metadata' => [ 'rowType' => [ 'fields' => [ @@ -645,12 +629,8 @@ public function testReadWithSnapshot() 'id' => self::TRANSACTION ] ], - 'rows' => [ - [ - 0 - ] - ] - ]); + 'values' => [0] + ])); $this->stubOperation(); @@ -665,9 +645,9 @@ public function testReadWithSnapshot() public function testReadWithTransaction() { - $this->connection->read(Argument::any()) + $this->connection->streamingRead(Argument::any()) ->shouldBeCalled() - ->willReturn([ + ->willReturn($this->resultGenerator([ 'metadata' => [ 'rowType' => [ 'fields' => [ @@ -683,12 +663,8 @@ public function testReadWithTransaction() 'id' => self::TRANSACTION ] ], - 'rows' => [ - [ - 0 - ] - ] - ]); + 'values' => [0] + ])); $this->stubOperation(); @@ -702,6 +678,23 @@ public function testReadWithTransaction() $this->assertInstanceOf(Transaction::class, $res->returnVal()->transaction()); } + public function testSessionPool() + { + $snippet = $this->snippetFromMethod(Database::class, 'sessionPool'); + $snippet->addLocal('database', $this->database); + + $res = $snippet->invoke('pool'); + $this->assertInstanceOf(SessionPoolInterface::class, $res->returnVal()); + } + + public function testClose() + { + $snippet = $this->snippetFromMethod(Database::class, 'close'); + $snippet->addLocal('database', $this->database); + + $res = $snippet->invoke(); + } + public function testIam() { $snippet = $this->snippetFromMethod(Database::class, 'iam'); @@ -710,4 +703,9 @@ public function testIam() $res = $snippet->invoke('iam'); $this->assertInstanceOf(Iam::class, $res->returnVal()); } + + private function resultGenerator(array $data) + { + yield $data; + } } diff --git a/tests/snippets/Spanner/InstanceTest.php b/tests/snippets/Spanner/InstanceTest.php index b5bb836df759..e30f2975c992 100644 --- a/tests/snippets/Spanner/InstanceTest.php +++ b/tests/snippets/Spanner/InstanceTest.php @@ -25,7 +25,6 @@ use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Database; use Google\Cloud\Spanner\Instance; -use Google\Cloud\Spanner\Session\SessionPoolInterface; use Prophecy\Argument; /** @@ -46,7 +45,6 @@ public function setUp() $this->connection = $this->prophesize(ConnectionInterface::class); $this->instance = \Google\Cloud\Dev\stub(Instance::class, [ $this->connection->reveal(), - $this->prophesize(SessionPoolInterface::class)->reveal(), $this->prophesize(LongRunningConnectionInterface::class)->reveal(), [], self::PROJECT, diff --git a/tests/snippets/Spanner/ResultTest.php b/tests/snippets/Spanner/ResultTest.php new file mode 100644 index 000000000000..9315b1e56a25 --- /dev/null +++ b/tests/snippets/Spanner/ResultTest.php @@ -0,0 +1,110 @@ +prophesize(Result::class); + $database = $this->prophesize(Database::class); + $result->rows() + ->willReturn($this->resultGenerator()); + $result->metadata() + ->willReturn([]); + $result->snapshot() + ->willReturn($this->prophesize(Snapshot::class)->reveal()); + $result->transaction() + ->willReturn($this->prophesize(Transaction::class)->reveal()); + $this->result = $result->reveal(); + $database->execute(Argument::any()) + ->willReturn($this->result); + $this->database = $database->reveal(); + } + + public function testClass() + { + $snippet = $this->snippetFromClass(Result::class); + $snippet->replace('$database =', '//$database ='); + $snippet->addLocal('database', $this->database); + $res = $snippet->invoke('result'); + $this->assertInstanceOf(Result::class, $res->returnVal()); + } + + public function testRows() + { + $snippet = $this->snippetFromMethod(Result::class, 'rows'); + $snippet->addLocal('result', $this->result); + $res = $snippet->invoke('rows'); + $this->assertInstanceOf(\Generator::class, $res->returnVal()); + } + + public function testMetadata() + { + $snippet = $this->snippetFromMethod(Result::class, 'metadata'); + $snippet->addLocal('result', $this->result); + $res = $snippet->invoke('metadata'); + $this->assertInternalType('array', $res->returnVal()); + } + + public function testStats() + { + $snippet = $this->snippetFromMethod(Result::class, 'metadata'); + $snippet->addLocal('result', $this->result); + $res = $snippet->invoke('metadata'); + $this->assertInternalType('array', $res->returnVal()); + } + + public function testSnapshot() + { + $snippet = $this->snippetFromMethod(Result::class, 'snapshot'); + $snippet->addLocal('result', $this->result); + $res = $snippet->invoke('snapshot'); + $this->assertInstanceOf(Snapshot::class, $res->returnVal()); + } + + public function testTransaction() + { + $snippet = $this->snippetFromMethod(Result::class, 'transaction'); + $snippet->addLocal('result', $this->result); + $res = $snippet->invoke('transaction'); + $this->assertInstanceOf(Transaction::class, $res->returnVal()); + } + + private function resultGenerator() + { + yield []; + } +} diff --git a/tests/snippets/Spanner/SnapshotTest.php b/tests/snippets/Spanner/SnapshotTest.php index 86d7ed889725..11b24f6e9cbd 100644 --- a/tests/snippets/Spanner/SnapshotTest.php +++ b/tests/snippets/Spanner/SnapshotTest.php @@ -82,9 +82,9 @@ public function testClass() public function testExecute() { - $this->connection->executeSql(Argument::any()) + $this->connection->executeStreamingSql(Argument::any()) ->shouldBeCalled() - ->willReturn([ + ->willReturn($this->resultGenerator([ 'metadata' => [ 'rowType' => [ 'fields' => [ @@ -97,12 +97,8 @@ public function testExecute() ] ] ], - 'rows' => [ - [ - 0 - ] - ] - ]); + 'values' => [0] + ])); $this->stubOperation(); @@ -115,9 +111,9 @@ public function testExecute() public function testRead() { - $this->connection->read(Argument::any()) + $this->connection->streamingRead(Argument::any()) ->shouldBeCalled() - ->willReturn([ + ->willReturn($this->resultGenerator([ 'metadata' => [ 'rowType' => [ 'fields' => [ @@ -130,12 +126,8 @@ public function testRead() ] ] ], - 'rows' => [ - [ - 0 - ] - ] - ]); + 'values' => [0] + ])); $this->stubOperation(); @@ -164,4 +156,9 @@ public function testReadTimestamp() $res = $snippet->invoke('timestamp'); $this->assertInstanceOf(Timestamp::class, $res->returnVal()); } + + private function resultGenerator(array $data) + { + yield $data; + } } diff --git a/tests/snippets/Spanner/SpannerClientTest.php b/tests/snippets/Spanner/SpannerClientTest.php index b715cf0f262f..125a6c9f57af 100644 --- a/tests/snippets/Spanner/SpannerClientTest.php +++ b/tests/snippets/Spanner/SpannerClientTest.php @@ -30,7 +30,6 @@ use Google\Cloud\Spanner\Instance; use Google\Cloud\Spanner\KeyRange; use Google\Cloud\Spanner\KeySet; -use Google\Cloud\Spanner\Session\SessionClient; use Google\Cloud\Spanner\SpannerClient; use Google\Cloud\Spanner\Timestamp; use Prophecy\Argument; @@ -252,15 +251,6 @@ public function testDuration() $this->assertInstanceOf(Duration::class, $res->returnVal()); } - public function testSessionClient() - { - $snippet = $this->snippetFromMethod(SpannerClient::class, 'sessionClient'); - $snippet->addLocal('spanner', $this->client); - - $res = $snippet->invoke('sessionClient'); - $this->assertInstanceOf(SessionClient::class, $res->returnVal()); - } - public function testResumeOperation() { $snippet = $this->snippetFromMethod(SpannerClient::class, 'resumeOperation'); diff --git a/tests/snippets/Spanner/TransactionTest.php b/tests/snippets/Spanner/TransactionTest.php index f0733e22545d..881adc9f9ca1 100644 --- a/tests/snippets/Spanner/TransactionTest.php +++ b/tests/snippets/Spanner/TransactionTest.php @@ -92,27 +92,9 @@ public function testClassReturnTransaction() public function testExecute() { - $this->connection->executeSql(Argument::any()) + $this->connection->executeStreamingSql(Argument::any()) ->shouldBeCalled() - ->willReturn([ - 'metadata' => [ - 'rowType' => [ - 'fields' => [ - [ - 'name' => 'loginCount', - 'type' => [ - 'code' => ValueMapper::TYPE_INT64 - ] - ] - ] - ] - ], - 'rows' => [ - [ - 0 - ] - ] - ]); + ->willReturn($this->resultGenerator()); $this->stubOperation(); @@ -125,27 +107,9 @@ public function testExecute() public function testRead() { - $this->connection->read(Argument::any()) + $this->connection->streamingRead(Argument::any()) ->shouldBeCalled() - ->willReturn([ - 'metadata' => [ - 'rowType' => [ - 'fields' => [ - [ - 'name' => 'loginCount', - 'type' => [ - 'code' => ValueMapper::TYPE_INT64 - ] - ] - ] - ] - ], - 'rows' => [ - [ - 0 - ] - ] - ]); + ->willReturn($this->resultGenerator()); $this->stubOperation(); @@ -327,4 +291,23 @@ public function testState() $res = $snippet->invoke('state'); $this->assertEquals(Transaction::STATE_ACTIVE, $res->returnVal()); } + + private function resultGenerator() + { + yield [ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'loginCount', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'values' => [0] + ]; + } } diff --git a/tests/snippets/Speech/SpeechClientTest.php b/tests/snippets/Speech/SpeechClientTest.php index 03c6fd70c636..5621d9527f99 100644 --- a/tests/snippets/Speech/SpeechClientTest.php +++ b/tests/snippets/Speech/SpeechClientTest.php @@ -36,7 +36,9 @@ public function setUp() { $this->testFile = "'" . __DIR__ . '/../fixtures/Speech/demo.flac' . "'"; $this->connection = $this->prophesize(ConnectionInterface::class); - $this->client = \Google\Cloud\Dev\stub(SpeechClient::class, ['languageCode' => 'en-US']); + $this->client = \Google\Cloud\Dev\stub(SpeechClient::class, [ + ['languageCode' => 'en-US'] + ]); $this->client->___setProperty('connection', $this->connection->reveal()); } diff --git a/tests/system/Spanner/OperationsTest.php b/tests/system/Spanner/OperationsTest.php index fcfb952858f2..f5113845eec5 100644 --- a/tests/system/Spanner/OperationsTest.php +++ b/tests/system/Spanner/OperationsTest.php @@ -63,7 +63,7 @@ public function testRead() $columns = ['id', 'name']; $res = $db->read(self::TEST_TABLE_NAME, $keySet, $columns); - $row = $res->firstRow(); + $row = $res->rows()->current(); $this->assertEquals($this->id, $row['id']); } @@ -137,6 +137,6 @@ private function getRow() ] ]); - return $res->firstRow(); + return $res->rows()->current(); } } diff --git a/tests/system/Spanner/SnapshotTest.php b/tests/system/Spanner/SnapshotTest.php index d6f6e78cf784..06becac07d3b 100644 --- a/tests/system/Spanner/SnapshotTest.php +++ b/tests/system/Spanner/SnapshotTest.php @@ -48,10 +48,12 @@ public function testSnapshot() private function getRow($client) { - return $client->execute('SELECT * FROM Users WHERE id=@id', [ + $result = $client->execute('SELECT * FROM Users WHERE id=@id', [ 'parameters' => [ 'id' => $this->id ] - ])->firstRow(); + ]); + + return $result->rows()->current(); } } diff --git a/tests/unit/Core/PhpArrayTest.php b/tests/unit/Core/PhpArrayTest.php index 1bb285b5190e..a7c5de6e2edf 100644 --- a/tests/unit/Core/PhpArrayTest.php +++ b/tests/unit/Core/PhpArrayTest.php @@ -98,7 +98,7 @@ class TestMessage extends Message { public $test_struct = null; public $test_labels = []; - public $test_stings = []; + public $test_strings = []; protected static $__extensions = array(); diff --git a/tests/unit/Spanner/DatabaseTest.php b/tests/unit/Spanner/DatabaseTest.php index 5216a69fc6f0..0dffb84bc936 100644 --- a/tests/unit/Spanner/DatabaseTest.php +++ b/tests/unit/Spanner/DatabaseTest.php @@ -64,7 +64,7 @@ public function setUp() $this->lro = $this->prophesize(LongRunningConnectionInterface::class); $this->lroCallables = []; - $this->sessionPool->session(self::INSTANCE, self::DATABASE, Argument::any()) + $this->sessionPool->acquire(Argument::type('string')) ->willReturn(new Session( $this->connection->reveal(), self::PROJECT, @@ -72,17 +72,21 @@ public function setUp() self::DATABASE, self::SESSION )); + $this->sessionPool->setDatabase(Argument::type(Database::class)) + ->willReturn(null); + $this->sessionPool->release(Argument::type(Session::class)) + ->willReturn(null); $this->instance->name()->willReturn(self::INSTANCE); $args = [ $this->connection->reveal(), $this->instance->reveal(), - $this->sessionPool->reveal(), $this->lro->reveal(), $this->lroCallables, self::PROJECT, self::DATABASE, + $this->sessionPool->reveal() ]; $props = [ @@ -528,35 +532,18 @@ public function testExecute() { $sql = 'SELECT * FROM Table'; - $this->connection->executeSql(Argument::that(function ($arg) use ($sql) { + $this->connection->executeStreamingSql(Argument::that(function ($arg) use ($sql) { if ($arg['sql'] !== $sql) return false; return true; - }))->shouldBeCalled()->willReturn([ - 'metadata' => [ - 'rowType' => [ - 'fields' => [ - [ - 'name' => 'ID', - 'type' => [ - 'code' => ValueMapper::TYPE_INT64 - ] - ] - ] - ] - ], - 'rows' => [ - [ - '10' - ] - ] - ]); + }))->shouldBeCalled()->willReturn($this->resultGenerator()); $this->refreshOperation(); $res = $this->database->execute($sql); $this->assertInstanceOf(Result::class, $res); - $this->assertEquals(10, $res->rows()[0]['ID']); + $rows = iterator_to_array($res->rows()); + $this->assertEquals(10, $rows[0]['ID']); } public function testRead() @@ -564,13 +551,28 @@ public function testRead() $table = 'Table'; $opts = ['foo' => 'bar']; - $this->connection->read(Argument::that(function ($arg) use ($table, $opts) { + $this->connection->streamingRead(Argument::that(function ($arg) use ($table, $opts) { if ($arg['table'] !== $table) return false; if ($arg['keySet']['all'] !== true) return false; if ($arg['columns'] !== ['ID']) return false; return true; - }))->shouldBeCalled()->willReturn([ + }))->shouldBeCalled()->willReturn($this->resultGenerator()); + + $this->refreshOperation(); + + $res = $this->database->read($table, new KeySet(['all' => true]), ['ID']); + $this->assertInstanceOf(Result::class, $res); + $rows = iterator_to_array($res->rows()); + $this->assertEquals(10, $rows[0]['ID']); + } + + // ******* + // Helpers + + private function resultGenerator() + { + yield [ 'metadata' => [ 'rowType' => [ 'fields' => [ @@ -583,23 +585,12 @@ public function testRead() ] ] ], - 'rows' => [ - [ - '10' - ] + 'values' => [ + '10' ] - ]); - - $this->refreshOperation(); - - $res = $this->database->read($table, new KeySet(['all' => true]), ['ID']); - $this->assertInstanceOf(Result::class, $res); - $this->assertEquals(10, $res->rows()[0]['ID']); + ]; } - // ******* - // Helpers - private function refreshOperation() { $operation = new Operation($this->connection->reveal(), false); diff --git a/tests/unit/Spanner/InstanceTest.php b/tests/unit/Spanner/InstanceTest.php index 5612ce712733..90533742c67d 100644 --- a/tests/unit/Spanner/InstanceTest.php +++ b/tests/unit/Spanner/InstanceTest.php @@ -48,7 +48,6 @@ public function setUp() $this->connection = $this->prophesize(ConnectionInterface::class); $this->instance = \Google\Cloud\Dev\stub(Instance::class, [ $this->connection->reveal(), - $this->prophesize(SessionPoolInterface::class)->reveal(), $this->prophesize(LongRunningConnectionInterface::class)->reveal(), [], self::PROJECT_ID, diff --git a/tests/unit/Spanner/OperationTest.php b/tests/unit/Spanner/OperationTest.php index 298e8531632a..e111d718959f 100644 --- a/tests/unit/Spanner/OperationTest.php +++ b/tests/unit/Spanner/OperationTest.php @@ -159,7 +159,7 @@ public function testExecute() $sql = 'SELECT * FROM Posts WHERE ID = @id'; $params = ['id' => 10]; - $this->connection->executeSql(Argument::that(function ($arg) use ($sql, $params) { + $this->connection->executeStreamingSql(Argument::that(function ($arg) use ($sql, $params) { if ($arg['sql'] !== $sql) return false; if ($arg['session'] !== self::SESSION) return false; if ($arg['params'] !== ['id' => '10']) return false; @@ -175,12 +175,13 @@ public function testExecute() ]); $this->assertInstanceOf(Result::class, $res); - $this->assertEquals(10, $res->rows()[0]['ID']); + $rows = iterator_to_array($res->rows()); + $this->assertEquals(10, $rows[0]['ID']); } public function testRead() { - $this->connection->read(Argument::that(function ($arg) { + $this->connection->streamingRead(Argument::that(function ($arg) { if ($arg['table'] !== 'Posts') return false; if ($arg['session'] !== self::SESSION) return false; if ($arg['keySet']['all'] !== true) return false; @@ -193,12 +194,13 @@ public function testRead() $res = $this->operation->read($this->session, 'Posts', new KeySet(['all' => true]), ['foo']); $this->assertInstanceOf(Result::class, $res); - $this->assertEquals(10, $res->rows()[0]['ID']); + $rows = iterator_to_array($res->rows()); + $this->assertEquals(10, $rows[0]['ID']); } public function testReadWithTransaction() { - $this->connection->read(Argument::that(function ($arg) { + $this->connection->streamingRead(Argument::that(function ($arg) { if ($arg['table'] !== 'Posts') return false; if ($arg['session'] !== self::SESSION) return false; if ($arg['keySet']['all'] !== true) return false; @@ -214,13 +216,15 @@ public function testReadWithTransaction() $res = $this->operation->read($this->session, 'Posts', new KeySet(['all' => true]), ['foo'], [ 'transactionContext' => SessionPoolInterface::CONTEXT_READWRITE ]); + $res->rows()->next(); + $this->assertInstanceOf(Transaction::class, $res->transaction()); $this->assertEquals(self::TRANSACTION, $res->transaction()->id()); } public function testReadWithSnapshot() { - $this->connection->read(Argument::that(function ($arg) { + $this->connection->streamingRead(Argument::that(function ($arg) { if ($arg['table'] !== 'Posts') return false; if ($arg['session'] !== self::SESSION) return false; if ($arg['keySet']['all'] !== true) return false; @@ -236,6 +240,8 @@ public function testReadWithSnapshot() $res = $this->operation->read($this->session, 'Posts', new KeySet(['all' => true]), ['foo'], [ 'transactionContext' => SessionPoolInterface::CONTEXT_READ ]); + $res->rows()->next(); + $this->assertInstanceOf(Snapshot::class, $res->snapshot()); $this->assertEquals(self::TRANSACTION, $res->snapshot()->id()); } @@ -282,7 +288,7 @@ public function testSnapshotWithTimestamp() private function executeAndReadResponse(array $additionalMetadata = []) { - return [ + yield [ 'metadata' => array_merge([ 'rowType' => [ 'fields' => [ @@ -295,8 +301,8 @@ private function executeAndReadResponse(array $additionalMetadata = []) ] ] ], $additionalMetadata), - 'rows' => [ - ['10'] + 'values' => [ + '10' ] ]; } diff --git a/tests/unit/Spanner/ResultTest.php b/tests/unit/Spanner/ResultTest.php index 98fa6d8ef6b5..f61fe1d08cc0 100644 --- a/tests/unit/Spanner/ResultTest.php +++ b/tests/unit/Spanner/ResultTest.php @@ -17,90 +17,135 @@ namespace Google\Cloud\Tests\Spanner; +use Google\Cloud\Spanner\Operation; use Google\Cloud\Spanner\Result; +use Google\Cloud\Spanner\Session\Session; +use Google\Cloud\Spanner\Snapshot; +use Google\Cloud\Spanner\Transaction; +use Google\Cloud\Spanner\ValueMapper; +use Prophecy\Argument; /** * @group spanner */ class ResultTest extends \PHPUnit_Framework_TestCase { - public function testIterator() + /** + * @dataProvider streamingDataProvider + */ + public function testRows($chunks, $expectedValues) { - $result = new Result([], [ - ['name' => 'John'] - ]); + $result = iterator_to_array($this->getResultClass($chunks)); - $res = iterator_to_array($result); - $this->assertEquals(1, count($res)); - $this->assertEquals('John', $res[0]['name']); + $this->assertEquals($expectedValues, $result); } - public function testMetadata() + public function streamingDataProvider() { - $result = new Result(['metadata' => 'foo'], []); - $this->assertEquals('foo', $result->metadata()); + foreach ($this->getStreamingDataFixture()['tests'] as $test) { + yield [$test['chunks'], $test['result']['value']]; + } } - public function testRows() + public function testIterator() { - $rows = [ - ['name' => 'John'] - ]; + $fixture = $this->getStreamingDataFixture()['tests'][0]; + $result = iterator_to_array($this->getResultClass($fixture['chunks'])); - $result = new Result([], $rows); - - $this->assertEquals($rows, $result->rows()); + $this->assertEquals($fixture['result']['value'], $result); } - public function testFirstRow() + public function testMetadata() { - $rows = [ - ['name' => 'John'], - ['name' => 'Dave'] - ]; + $fixture = $this->getStreamingDataFixture()['tests'][0]; + $result = $this->getResultClass($fixture['chunks']); + $expectedMetadata = json_decode($fixture['chunks'][0], true)['metadata']; - $result = new Result([], $rows); - - $this->assertEquals($rows[0], $result->firstRow()); + $this->assertNull($result->stats()); + $result->rows()->next(); + $this->assertEquals($expectedMetadata, $result->metadata()); } public function testStats() { - $result = new Result(['stats' => 'foo'], []); - $this->assertEquals('foo', $result->stats()); - } + $fixture = $this->getStreamingDataFixture()['tests'][1]; + $result = $this->getResultClass($fixture['chunks']); + $expectedStats = json_decode($fixture['chunks'][0], true)['stats']; - public function testInfo() - { - $info = ['foo' => 'bar']; - $result = new Result($info, []); - - $this->assertEquals($info, $result->info()); + $this->assertNull($result->stats()); + $result->rows()->next(); + $this->assertEquals($expectedStats, $result->stats()); } public function testTransaction() { - $result = new Result([], [], [ - 'transaction' => 'foo' - ]); - - $this->assertEquals('foo', $result->transaction()); - - $result = new Result([], []); + $fixture = $this->getStreamingDataFixture()['tests'][1]; + $result = $this->getResultClass($fixture['chunks'], 'rw'); $this->assertNull($result->transaction()); + $result->rows()->next(); + $this->assertInstanceOf(Transaction::class, $result->transaction()); } public function testSnapshot() { - $result = new Result([], [], [ - 'snapshot' => 'foo' - ]); + $fixture = $this->getStreamingDataFixture()['tests'][1]; + $result = $this->getResultClass($fixture['chunks']); - $this->assertEquals('foo', $result->snapshot()); + $this->assertNull($result->snapshot()); + $result->rows()->next(); + $this->assertInstanceOf(Snapshot::class, $result->snapshot()); + } + + private function getResultClass($chunks, $context = 'r') + { + $operation = $this->prophesize(Operation::class); + $session = $this->prophesize(Session::class)->reveal(); + $mapper = $this->prophesize(ValueMapper::class); + $transaction = $this->prophesize(Transaction::class); + $snapshot = $this->prophesize(Snapshot::class); + $mapper->decodeValues( + Argument::any(), + Argument::any() + )->will(function ($args) { + return $args[1]; + }); + + if ($context === 'r') { + $operation->createSnapshot( + $session, + Argument::type('array') + )->willReturn($snapshot->reveal()); + } else { + $operation->createTransaction( + $session, + Argument::type('array') + )->willReturn($transaction->reveal()); + } + + return new Result( + $operation->reveal(), + $session, + function () use ($chunks) { + return $this->resultGenerator($chunks); + }, + $context, + $mapper->reveal() + ); + } - $result = new Result([], []); + private function resultGenerator($chunks) + { + foreach ($chunks as $chunk) { + yield json_decode($chunk, true); + } + } - $this->assertNull($result->snapshot()); + private function getStreamingDataFixture() + { + return json_decode( + file_get_contents(__DIR__ .'/../fixtures/spanner/streaming-read-acceptance-test.json'), + true + ); } } diff --git a/tests/unit/Spanner/Session/CacheSessionPoolTest.php b/tests/unit/Spanner/Session/CacheSessionPoolTest.php new file mode 100644 index 000000000000..47a494536233 --- /dev/null +++ b/tests/unit/Spanner/Session/CacheSessionPoolTest.php @@ -0,0 +1,471 @@ +time = time(); + } + + /** + * @dataProvider badConfigDataProvider + */ + public function testThrowsExceptionWithInvalidConfig($config) + { + $exceptionThrown = false; + + try { + new CacheSessionPool($this->getCacheItemPool(), $config); + } catch (\InvalidArgumentException $ex) { + $exceptionThrown = true; + } + + $this->assertTrue($exceptionThrown); + } + + public function badConfigDataProvider() + { + return [ + [['maxSessions' => -1]], + [['minSessions' => -1]], + [['maxCyclesToWaitForSession' => -1]], + [['sleepIntervalSeconds' => -1]], + [['minSessions' => 5, 'maxSessions' => 1]], + [['lock' => new \stdClass]] + ]; + } + + /** + * @expectedException \RuntimeException + */ + public function testAcquireThrowsExceptionWhenMaxCyclesMet() + { + $config = [ + 'maxSessions' => 1, + 'maxCyclesToWaitForSession' => 1 + ]; + $cacheData = [ + 'queue' => [], + 'inUse' => [ + 'alreadyCheckedOut' => [ + 'name' => 'alreadyCheckedOut', + 'expiration' => $this->time + 3600, + 'lastActive' => $this->time + ] + ], + 'toCreate' => [] + ]; + $pool = new CacheSessionPoolStub($this->getCacheItemPool($cacheData), $config, $this->time); + $pool->setDatabase($this->getDatabase()); + $pool->acquire(); + } + + /** + * @expectedException \RuntimeException + */ + public function testAcquireThrowsExceptionWithNoAvailableSessions() + { + $config = [ + 'maxSessions' => 1, + 'shouldWaitForSession' => false + ]; + $cacheData = [ + 'queue' => [], + 'inUse' => [ + 'alreadyCheckedOut' => [ + 'name' => 'alreadyCheckedOut', + 'expiration' => $this->time + 3600, + 'lastActive' => $this->time + ] + ], + 'toCreate' => [] + ]; + $pool = new CacheSessionPoolStub($this->getCacheItemPool($cacheData), $config, $this->time); + $pool->setDatabase($this->getDatabase()); + $pool->acquire(); + } + + public function testAcquireRemovesToCreateItemsIfCreateCallFails() + { + $exceptionThrown = false; + $config = ['maxSessions' => 1]; + $pool = new CacheSessionPoolStub($this->getCacheItemPool(), $config, $this->time); + $pool->setDatabase($this->getDatabase(true)); + + try { + $actualSession = $pool->acquire(); + } catch (\Exception $ex) { + $exceptionThrown = true; + } + + $actualItemPool = $pool->cacheItemPool(); + $actualCacheData = $actualItemPool->getItem( + sprintf(self::CACHE_KEY_TEMPLATE, self::INSTANCE_NAME, self::DATABASE_NAME) + )->get(); + + $this->assertEmpty($actualCacheData['toCreate']); + $this->assertTrue($exceptionThrown); + } + + public function testRelease() + { + $cacheData = [ + 'queue' => [], + 'inUse' => [ + 'session' => [ + 'name' => 'session', + 'expiration' => $this->time + 3600, + 'lastActive' => $this->time + ] + ], + 'toCreate' => [] + ]; + $expectedCacheData = [ + 'queue' => [ + [ + 'name' => 'session', + 'expiration' => $this->time + 3600 + ] + ], + 'inUse' => [], + 'toCreate' => [] + ]; + $session = $this->prophesize(Session::class); + $session->name() + ->willReturn('session'); + $session->expiration() + ->willReturn($this->time + 3600); + $pool = new CacheSessionPoolStub($this->getCacheItemPool($cacheData), [], $this->time); + $pool->setDatabase($this->getDatabase()); + $pool->release($session->reveal()); + $actualItemPool = $pool->cacheItemPool(); + $actualCacheData = $actualItemPool->getItem( + sprintf(self::CACHE_KEY_TEMPLATE, self::INSTANCE_NAME, self::DATABASE_NAME) + )->get(); + + $this->assertEquals($expectedCacheData, $actualCacheData); + } + + public function testClearPool() + { + $pool = new CacheSessionPoolStub($this->getCacheItemPool()); + $pool->clear(); + $actualItemPool = $pool->cacheItemPool(); + $actualCacheData = $actualItemPool->getItem( + sprintf(self::CACHE_KEY_TEMPLATE, self::INSTANCE_NAME, self::DATABASE_NAME) + )->get(); + + $this->assertNull($actualCacheData); + } + + /** + * @dataProvider acquireDataProvider + */ + public function testAcquire($config, $cacheData, $expectedCacheData, $time) + { + $pool = new CacheSessionPoolStub($this->getCacheItemPool($cacheData), $config, $time); + $pool->setDatabase($this->getDatabase()); + $actualSession = $pool->acquire(); + $actualItemPool = $pool->cacheItemPool(); + $actualCacheData = $actualItemPool->getItem( + sprintf(self::CACHE_KEY_TEMPLATE, self::INSTANCE_NAME, self::DATABASE_NAME) + )->get(); + + $this->assertInstanceOf(Session::class, $actualSession); + $this->assertEquals($expectedCacheData, $actualCacheData); + } + + public function acquireDataProvider() + { + $time = time(); + + return [ + // Set #0: Initialize data using default config + [ + [], + null, + [ + 'queue' => [], + 'inUse' => [ + 'session0' => [ + 'name' => 'session0', + 'expiration' => $time + 3600, + 'lastActive' => $time + ] + ], + 'toCreate' => [] + ], + $time + ], + // Set #1: Purge expired session from queue and create sessions up to min + [ + ['minSessions' => 3], + [ + 'queue' => [ + [ + 'name' => 'expired', + 'expiration' => $time - 3000 + ], + [ + 'name' => 'stillValid', + 'expiration' => $time + 3600 + ] + ], + 'inUse' => [], + 'toCreate' => [] + ], + [ + 'queue' => [ + [ + 'name' => 'session0', + 'expiration' => $time + 3600 + ], + [ + 'name' => 'session1', + 'expiration' => $time + 3600 + ] + ], + 'inUse' => [ + 'stillValid' => [ + 'name' => 'stillValid', + 'expiration' => $time + 3600, + 'lastActive' => $time + ] + ], + 'toCreate' => [] + ], + $time + ], + // // Set #2: Create a new session when all available are checked out + // // and we have not reached the max limit + [ + [], + [ + 'queue' => [], + 'inUse' => [ + 'alreadyCheckedOut' => [ + 'name' => 'alreadyCheckedOut', + 'expiration' => $time + 3600, + 'lastActive' => $time + ] + ], + 'toCreate' => [] + ], + [ + 'queue' => [], + 'inUse' => [ + 'session0' => [ + 'name' => 'session0', + 'expiration' => $time + 3600, + 'lastActive' => $time + ], + 'alreadyCheckedOut' => [ + 'name' => 'alreadyCheckedOut', + 'expiration' => $time + 3600, + 'lastActive' => $time + ] + ], + 'toCreate' => [] + ], + $time + ], + // Set #3: Run clean up on abandoned items and create new + [ + ['maxSessions' => 3], + [ + 'queue' => [], + 'inUse' => [ + 'expiredInUse1' => [ + 'name' => 'expiredInUse1', + 'expiration' => $time - 5000, + 'lastActive' => $time - 1201 + ], + 'expiredInUse2' => [ + 'name' => 'expiredInUse2', + 'expiration' => $time - 5000, + 'lastActive' => $time - 1201 + ] + ], + 'toCreate' => [ + 'oldguy' => $time - 1201 + ] + ], + [ + 'queue' => [], + 'inUse' => [ + 'session0' => [ + 'name' => 'session0', + 'expiration' => $time + 3600, + 'lastActive' => $time + ] + ], + 'toCreate' => [] + ], + $time + ], + // Set #4: Basic test, check out session from queue + [ + [], + [ + 'queue' => [ + [ + 'name' => 'session', + 'expiration' => $time + 3600 + ] + ], + 'inUse' => [], + 'toCreate' => [] + ], + [ + 'queue' => [], + 'inUse' => [ + 'session' => [ + 'name' => 'session', + 'expiration' => $time + 3600, + 'lastActive' => $time + ] + ], + 'toCreate' => [] + ], + $time + ], + // Set #5: Session expires in a half hour, check validity against API + [ + [], + [ + 'queue' => [ + [ + 'name' => 'expiresSoon', + 'expiration' => $time + 1500 + ], + [ + 'name' => 'session', + 'expiration' => $time + 3600 + ] + ], + 'inUse' => [], + 'toCreate' => [] + ], + [ + 'queue' => [], + 'inUse' => [ + 'session' => [ + 'name' => 'session', + 'expiration' => $time + 3600, + 'lastActive' => $time + ] + ], + 'toCreate' => [] + ], + $time + ] + ]; + } + + private function getDatabase($shouldCreateThrowException = false) + { + $database = $this->prophesize(Database::class); + $createdSession = $this->prophesize(Session::class); + $session = $this->prophesize(Session::class); + $createdSession->expiration() + ->willReturn($this->time + 3600); + $session->expiration() + ->willReturn($this->time + 3600); + $session->exists() + ->willReturn(false); + $database->session(Argument::any()) + ->will(function ($args) use ($session) { + $session->name() + ->willReturn($args[0]); + + return $session->reveal(); + }); + $database->identity() + ->willReturn([ + 'database' => self::DATABASE_NAME, + 'instance' => self::INSTANCE_NAME + ]); + + if ($shouldCreateThrowException) { + $database->createSession() + ->willThrow(new \Exception()); + } else { + $database->createSession() + ->will(function ($args, $mock, $method) use ($createdSession) { + $methodCalls = $mock->findProphecyMethodCalls( + $method->getMethodName(), + new ArgumentsWildcard($args) + ); + + $createdSession->name() + ->willReturn('session' . count($methodCalls)); + + return $createdSession; + }); + } + + return $database->reveal(); + } + + private function getCacheItemPool(array $cacheData = null) + { + $cacheItemPool = new MemoryCacheItemPool(); + $cacheItem = $cacheItemPool->getItem( + sprintf(self::CACHE_KEY_TEMPLATE, self::INSTANCE_NAME, self::DATABASE_NAME) + ); + $cacheItemPool->save($cacheItem->set($cacheData)); + + return $cacheItemPool; + } +} + +class CacheSessionPoolStub extends CacheSessionPool +{ + private $time; + + public function __construct(CacheItemPoolInterface $cacheItemPool, array $config = [], $time = null) + { + $this->time = $time; + parent::__construct($cacheItemPool, $config); + } + + protected function time() + { + return $this->time ?: parent::$this->time(); + } +} diff --git a/tests/unit/Spanner/SpannerClientTest.php b/tests/unit/Spanner/SpannerClientTest.php index b58b8ff37b90..e7006cbe2a68 100644 --- a/tests/unit/Spanner/SpannerClientTest.php +++ b/tests/unit/Spanner/SpannerClientTest.php @@ -29,7 +29,6 @@ use Google\Cloud\Spanner\Instance; use Google\Cloud\Spanner\KeyRange; use Google\Cloud\Spanner\KeySet; -use Google\Cloud\Spanner\Session\SessionClient; use Google\Cloud\Spanner\SpannerClient; use Google\Cloud\Spanner\Timestamp; use Prophecy\Argument; @@ -279,10 +278,4 @@ public function testDuration() $d = $this->client->duration(10, 1); $this->assertInstanceOf(Duration::class, $d); } - - public function testSessionClient() - { - $sc = $this->client->sessionClient(); - $this->assertInstanceOf(SessionClient::class, $sc); - } } diff --git a/tests/unit/Spanner/TransactionTest.php b/tests/unit/Spanner/TransactionTest.php index 867ae378dbb2..db5e71d9ad0d 100644 --- a/tests/unit/Spanner/TransactionTest.php +++ b/tests/unit/Spanner/TransactionTest.php @@ -175,36 +175,19 @@ public function testExecute() { $sql = 'SELECT * FROM Table'; - $this->connection->executeSql(Argument::that(function ($arg) use ($sql) { + $this->connection->executeStreamingSql(Argument::that(function ($arg) use ($sql) { if ($arg['transaction']['id'] !== self::TRANSACTION) return false; if ($arg['sql'] !== $sql) return false; return true; - }))->shouldBeCalled()->willReturn([ - 'metadata' => [ - 'rowType' => [ - 'fields' => [ - [ - 'name' => 'ID', - 'type' => [ - 'code' => ValueMapper::TYPE_INT64 - ] - ] - ] - ] - ], - 'rows' => [ - [ - '10' - ] - ] - ]); + }))->shouldBeCalled()->willReturn($this->resultGenerator()); $this->refreshOperation(); $res = $this->transaction->execute($sql); $this->assertInstanceOf(Result::class, $res); - $this->assertEquals(10, $res->rows()[0]['ID']); + $rows = iterator_to_array($res->rows()); + $this->assertEquals(10, $rows[0]['ID']); } public function testRead() @@ -212,38 +195,22 @@ public function testRead() $table = 'Table'; $opts = ['foo' => 'bar']; - $this->connection->read(Argument::that(function ($arg) use ($table, $opts) { + $this->connection->streamingRead(Argument::that(function ($arg) use ($table, $opts) { if ($arg['transaction']['id'] !== self::TRANSACTION) return false; if ($arg['table'] !== $table) return false; if ($arg['keySet']['all'] !== true) return false; if ($arg['columns'] !== ['ID']) return false; return true; - }))->shouldBeCalled()->willReturn([ - 'metadata' => [ - 'rowType' => [ - 'fields' => [ - [ - 'name' => 'ID', - 'type' => [ - 'code' => ValueMapper::TYPE_INT64 - ] - ] - ] - ] - ], - 'rows' => [ - [ - '10' - ] - ] - ]); + }))->shouldBeCalled()->willReturn($this->resultGenerator()); $this->refreshOperation(); $res = $this->transaction->read($table, new KeySet(['all' => true]), ['ID']); + $this->assertInstanceOf(Result::class, $res); - $this->assertEquals(10, $res->rows()[0]['ID']); + $rows = iterator_to_array($res->rows()); + $this->assertEquals(10, $rows[0]['ID']); } public function testCommit() @@ -304,6 +271,27 @@ public function testState() // ******* // Helpers + private function resultGenerator() + { + yield [ + 'metadata' => [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'ID', + 'type' => [ + 'code' => ValueMapper::TYPE_INT64 + ] + ] + ] + ] + ], + 'values' => [ + '10' + ] + ]; + } + private function refreshOperation() { $operation = new Operation($this->connection->reveal(), false); diff --git a/tests/unit/fixtures/spanner/streaming-read-acceptance-test.json b/tests/unit/fixtures/spanner/streaming-read-acceptance-test.json new file mode 100644 index 000000000000..718e782c9839 --- /dev/null +++ b/tests/unit/fixtures/spanner/streaming-read-acceptance-test.json @@ -0,0 +1,371 @@ +{ + "tests": [ + { + "result": { + "value": [ + [ + true, + "abc", + "100", + 1.1, + "YWJj", + [ + "abc", + "def", + null, + "ghi" + ], + [ + [ + "abc" + ], + [ + "def" + ], + [ + "ghi" + ] + ] + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 1\n }\n }, {\n \"name\": \"f2\",\n \"type\": {\n \"code\": 6\n }\n }, {\n \"name\": \"f3\",\n \"type\": {\n \"code\": 2\n }\n }, {\n \"name\": \"f4\",\n \"type\": {\n \"code\": 3\n }\n }, {\n \"name\": \"f5\",\n \"type\": {\n \"code\": 7\n }\n }, {\n \"name\": \"f6\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 6\n }\n }\n }, {\n \"name\": \"f7\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 9,\n \"structType\": {\n \"fields\": [{\n \"name\": \"f71\",\n \"type\": {\n \"code\": 6\n }\n }]\n }\n }\n }\n }]\n }\n },\n \"values\": [true, \"abc\", \"100\", 1.1, \"YWJj\", [\"abc\", \"def\", null, \"ghi\"], [[\"abc\"], [\"def\"], [\"ghi\"]]]\n}" + ], + "name": "Basic Test" + }, + { + "result": { + "value": [ + [ + true, + "abc", + "100", + 1.1, + "YWJj", + [ + "abc", + "def", + null, + "ghi" + ], + [ + [ + "abc" + ], + [ + "def" + ], + [ + "ghi" + ] + ] + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"transaction\": {\n \"id\": 1},\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 1\n }\n }, {\n \"name\": \"f2\",\n \"type\": {\n \"code\": 6\n }\n }, {\n \"name\": \"f3\",\n \"type\": {\n \"code\": 2\n }\n }, {\n \"name\": \"f4\",\n \"type\": {\n \"code\": 3\n }\n }, {\n \"name\": \"f5\",\n \"type\": {\n \"code\": 7\n }\n }, {\n \"name\": \"f6\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 6\n }\n }\n }, {\n \"name\": \"f7\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 9,\n \"structType\": {\n \"fields\": [{\n \"name\": \"f71\",\n \"type\": {\n \"code\": 6\n }\n }]\n }\n }\n }\n }]\n }\n },\n \"stats\": true,\n \"values\": [true, \"abc\", \"100\", 1.1, \"YWJj\", [\"abc\", \"def\", null, \"ghi\"], [[\"abc\"], [\"def\"], [\"ghi\"]]]\n}" + ], + "name": "Basic Test w/ stats and transaction id" + }, + { + "result": { + "value": [ + [ + "abcdefghi" + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 6\n }\n }]\n }\n },\n \"values\": [\"abc\"],\n \"chunkedValue\": true\n}", + "{\n \"values\": [\"def\"],\n \"chunkedValue\": true\n}", + "{\n \"values\": [\"ghi\"]\n}" + ], + "name": "String Chunking Test" + }, + { + "result": { + "value": [ + [ + [ + "abc", + "def", + "ghi", + "jkl" + ] + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 6\n }\n }\n }]\n }\n },\n \"values\": [[\"abc\", \"d\"]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[\"ef\", \"gh\"]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[\"i\", \"jkl\"]]\n}" + ], + "name": "String Array Chunking Test" + }, + { + "result": { + "value": [ + [ + [ + "abc", + "def", + null, + "ghi", + null, + "jkl" + ] + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 6\n }\n }\n }]\n }\n },\n \"values\": [[\"abc\", \"def\"]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[null, \"ghi\"]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[null, \"jkl\"]]\n}" + ], + "name": "String Array Chunking Test With Nulls" + }, + { + "result": { + "value": [ + [ + [ + "abc", + "def", + "ghi", + "jkl" + ] + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 6\n }\n }\n }]\n }\n },\n \"values\": [[\"abc\", \"def\"]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[\"\", \"ghi\"]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[\"\", \"jkl\"]]\n}" + ], + "name": "String Array Chunking Test With Empty Strings" + }, + { + "result": { + "value": [ + [ + [ + "abcdefghi" + ] + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 6\n }\n }\n }]\n }\n },\n \"values\": [[\"abc\"]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[\"def\"]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[\"ghi\"]]\n}" + ], + "name": "String Array Chunking Test With One Large String" + }, + { + "result": { + "value": [ + [ + [ + "1", + "23", + "4", + null, + 5 + ] + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 2\n }\n }\n }]\n }\n },\n \"values\": [[\"1\", \"2\"]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[\"3\", \"4\"]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[\"\", null, \"5\"]]\n}" + ], + "name": "INT64 Array Chunking Test" + }, + { + "result": { + "value": [ + [ + [ + 1, + 2, + "Infinity", + "-Infinity", + "NaN", + null, + 3 + ] + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 3\n }\n }\n }]\n }\n },\n \"values\": [[1.0, 2.0]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[\"Infinity\", \"-Infinity\", \"NaN\"]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[\"\", null, 3.0]]\n}" + ], + "name": "FLOAT64 Array Chunking Test" + }, + { + "result": { + "value": [ + [ + [ + [ + "abc", + "defghi" + ], + [ + "123", + "456" + ] + ] + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 9,\n \"structType\": {\n \"fields\": [{\n \"name\": \"f11\",\n \"type\": {\n \"code\": 6\n }\n }, {\n \"name\": \"f12\",\n \"type\": {\n \"code\": 6\n }\n }]\n }\n }\n }\n }]\n }\n },\n \"values\": [[[\"abc\", \"def\"]]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[[\"ghi\"], [\"123\", \"456\"]]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[[\"\"]]]\n}" + ], + "name": "Struct Array Chunking Test" + }, + { + "result": { + "value": [ + [ + [ + [ + [ + [ + "abc" + ] + ] + ] + ] + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 9,\n \"structType\": {\n \"fields\": [{\n \"name\": \"f11\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 9,\n \"structType\": {\n \"fields\": [{\n \"name\": \"f12\",\n \"type\": {\n \"code\": 6\n }\n }]\n }\n }\n }\n }]\n }\n }\n }\n }]\n }\n },\n \"values\": [[[[[\"abc\"]]]]]\n}" + ], + "name": "Nested Struct Array Test" + }, + { + "result": { + "value": [ + [ + [ + [ + [ + [ + "abc" + ], + [ + "def" + ] + ] + ] + ] + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 9,\n \"structType\": {\n \"fields\": [{\n \"name\": \"f11\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 9,\n \"structType\": {\n \"fields\": [{\n \"name\": \"f12\",\n \"type\": {\n \"code\": 6\n }\n }]\n }\n }\n }\n }]\n }\n }\n }\n }]\n }\n },\n \"values\": [[[[[\"ab\"]]]]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[[[[\"c\"], [\"def\"]]]]]\n}" + ], + "name": "Nested Struct Array Chunking Test" + }, + { + "result": { + "value": [ + [ + "1", + [ + [ + "ab" + ] + ] + ], + [ + "2", + [ + [ + "c" + ] + ] + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 6\n }\n }, {\n \"name\": \"f2\",\n \"type\": {\n \"code\": 8,\n \"arrayElementType\": {\n \"code\": 9,\n \"structType\": {\n \"fields\": [{\n \"name\": \"f21\",\n \"type\": {\n \"code\": 6\n }\n }]\n }\n }\n }\n }]\n }\n },\n \"values\": [\"1\", [[\"a\"]]],\n \"chunkedValue\": true\n}", + "{\n \"values\": [[[\"b\"]], \"2\"],\n \"chunkedValue\": true\n}", + "{\n \"values\": [\"\", [[\"c\"]]]\n}" + ], + "name": "Struct Array And String Chunking Test" + }, + { + "result": { + "value": [ + [ + "abc", + "1" + ], + [ + "def", + "2" + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 6\n }\n }, {\n \"name\": \"f2\",\n \"type\": {\n \"code\": 2\n }\n }]\n }\n },\n \"values\": [\"abc\", \"1\", \"def\", \"2\"]\n}" + ], + "name": "Multiple Row Single Chunk" + }, + { + "result": { + "value": [ + [ + "abc", + "1" + ], + [ + "def", + "2" + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 6\n }\n }, {\n \"name\": \"f2\",\n \"type\": {\n \"code\": 2\n }\n }]\n }\n },\n \"values\": [\"ab\"],\n \"chunkedValue\": true\n}", + "{\n \"values\": [\"c\", \"1\", \"de\"],\n \"chunkedValue\": true\n}", + "{\n \"values\": [\"f\", \"2\"]\n}" + ], + "name": "Multiple Row Multiple Chunks" + }, + { + "result": { + "value": [ + [ + "ab" + ], + [ + "c" + ], + [ + "d" + ], + [ + "ef" + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 6\n }\n }]\n }\n },\n \"values\": [\"a\"],\n \"chunkedValue\": true\n}", + "{\n \"values\": [\"b\", \"c\"]\n}", + "{\n \"values\": [\"d\", \"e\"],\n \"chunkedValue\": true\n}", + "{\n \"values\": [\"f\"]\n}" + ], + "name": "Multiple Row Chunks/Non Chunks Interleaved" + } + ] +} From 16292729ba88fdc883f7da5b3089919730ab5257 Mon Sep 17 00:00:00 2001 From: jdpedrie Date: Mon, 10 Apr 2017 10:02:05 -0400 Subject: [PATCH 03/11] Implement remaining API, add system tests. --- composer.json | 3 +- docs/contents/cloud-spanner.json | 42 +- docs/manifest.json | 8 + src/Core/Exception/AbortedException.php | 12 +- src/Core/Exception/ServiceException.php | 21 +- src/Core/GrpcRequestWrapper.php | 28 +- src/Core/GrpcTrait.php | 9 + src/Core/LongRunning/LROTrait.php | 94 ++- src/Core/LongRunning/LongRunningOperation.php | 10 +- .../LongRunning/OperationResponseTrait.php | 13 +- src/Core/PhpArray.php | 2 +- src/Logging/Connection/Grpc.php | 20 +- .../Connection/ConnectionInterface.php | 109 +-- src/Spanner/Connection/Grpc.php | 184 +++-- src/Spanner/Database.php | 455 ++++++---- src/Spanner/Date.php | 21 + src/Spanner/Instance.php | 167 ++-- ...guration.php => InstanceConfiguration.php} | 36 +- src/Spanner/KeyRange.php | 26 +- src/Spanner/KeySet.php | 30 +- src/Spanner/Operation.php | 74 +- src/Spanner/Result.php | 3 +- src/Spanner/Session/CacheSessionPool.php | 5 +- src/Spanner/Snapshot.php | 37 +- src/Spanner/SpannerClient.php | 96 +-- src/Spanner/Transaction.php | 71 +- src/Spanner/TransactionConfigurationTrait.php | 77 +- src/Spanner/TransactionalReadInterface.php | 67 ++ ...adTrait.php => TransactionalReadTrait.php} | 88 +- src/Spanner/V1/SpannerClient.php | 1 + src/Spanner/ValueMapper.php | 51 +- .../snippets/Language/LanguageClientTest.php | 2 +- tests/snippets/PubSub/PubSubClientTest.php | 4 +- tests/snippets/PubSub/SnapshotTest.php | 8 +- tests/snippets/PubSub/SubscriptionTest.php | 6 +- tests/snippets/ServiceBuilderTest.php | 10 +- tests/snippets/Spanner/BytesTest.php | 4 + tests/snippets/Spanner/DatabaseTest.php | 136 ++- tests/snippets/Spanner/DateTest.php | 13 + tests/snippets/Spanner/DurationTest.php | 4 + ...Test.php => InstanceConfigurationTest.php} | 39 +- tests/snippets/Spanner/InstanceTest.php | 90 +- tests/snippets/Spanner/KeyRangeTest.php | 23 + tests/snippets/Spanner/KeySetTest.php | 4 + tests/snippets/Spanner/ResultTest.php | 20 +- .../Spanner/Session/CacheSessionPoolTest.php | 42 + tests/snippets/Spanner/SnapshotTest.php | 10 +- tests/snippets/Spanner/SpannerClientTest.php | 44 +- tests/snippets/Spanner/TimestampTest.php | 4 + tests/snippets/Spanner/TransactionTest.php | 4 + tests/snippets/bootstrap.php | 4 +- tests/system/Spanner/AdminTest.php | 32 +- tests/system/Spanner/ConfigurationTest.php | 53 -- tests/system/Spanner/OperationsTest.php | 122 ++- tests/system/Spanner/SpannerTestCase.php | 12 +- tests/system/Spanner/TransactionTest.php | 87 +- tests/unit/Spanner/BytesTest.php | 2 +- tests/unit/Spanner/Connection/GrpcTest.php | 359 ++++++++ tests/unit/Spanner/DatabaseTest.php | 91 +- tests/unit/Spanner/DateTest.php | 8 +- tests/unit/Spanner/DurationTest.php | 2 +- ...Test.php => InstanceConfigurationTest.php} | 20 +- tests/unit/Spanner/InstanceTest.php | 48 +- tests/unit/Spanner/KeyRangeTest.php | 18 +- tests/unit/Spanner/KeySetTest.php | 20 +- tests/unit/Spanner/OperationTest.php | 16 +- tests/unit/Spanner/ResultTest.php | 68 +- tests/unit/Spanner/ResultTestTrait.php | 96 +++ .../Spanner/Session/CacheSessionPoolTest.php | 2 +- tests/unit/Spanner/SnapshotTest.php | 61 +- tests/unit/Spanner/SpannerClientTest.php | 56 +- tests/unit/Spanner/TimestampTest.php | 2 +- .../TransactionConfigurationTraitTest.php | 17 +- tests/unit/Spanner/TransactionTest.php | 22 +- tests/unit/Spanner/TransactionTypeTest.php | 777 ++++++++++++++++++ tests/unit/Spanner/ValueMapperTest.php | 6 +- 76 files changed, 3439 insertions(+), 889 deletions(-) rename src/Spanner/{Configuration.php => InstanceConfiguration.php} (82%) create mode 100644 src/Spanner/TransactionalReadInterface.php rename src/Spanner/{TransactionReadTrait.php => TransactionalReadTrait.php} (56%) rename tests/snippets/Spanner/{ConfigurationTest.php => InstanceConfigurationTest.php} (62%) create mode 100644 tests/snippets/Spanner/Session/CacheSessionPoolTest.php delete mode 100644 tests/system/Spanner/ConfigurationTest.php create mode 100644 tests/unit/Spanner/Connection/GrpcTest.php rename tests/unit/Spanner/{ConfigurationTest.php => InstanceConfigurationTest.php} (79%) create mode 100644 tests/unit/Spanner/ResultTestTrait.php create mode 100644 tests/unit/Spanner/TransactionTypeTest.php diff --git a/composer.json b/composer.json index 8b061ebcd98f..aaff7d30aaa7 100644 --- a/composer.json +++ b/composer.json @@ -72,7 +72,8 @@ "autoload-dev": { "psr-4": { "Google\\Cloud\\Dev\\": "dev/src", - "Google\\Cloud\\Tests\\System\\": "tests/system" + "Google\\Cloud\\Tests\\System\\": "tests/system", + "Google\\Cloud\\Tests\\Unit\\": "tests/unit" }, "files": ["dev/src/Functions.php"] }, diff --git a/docs/contents/cloud-spanner.json b/docs/contents/cloud-spanner.json index 191f68771ba9..f3459b3c5aa0 100644 --- a/docs/contents/cloud-spanner.json +++ b/docs/contents/cloud-spanner.json @@ -1,8 +1,44 @@ { "title": "Spanner", "pattern": "spanner\/\\w{1,}", - "nav": [{ - "title": "SpannerClient", - "type": "spanner/spannerclient" + "services": [{ + "title": "SpannerClient", + "type": "spanner/spannerclient" + }, { + "title": "Configuration", + "type": "spanner/configuration" + }, { + "title": "Database", + "type": "spanner/database" + }, { + "title": "Instance", + "type": "spanner/instance" + }, { + "title": "Snapshot", + "type": "spanner/snapshot" + }, { + "title": "Transaction", + "type": "spanner/transaction" + }, { + "title": "Bytes", + "type": "spanner/bytes" + }, { + "title": "Date", + "type": "spanner/date" + }, { + "title": "Duration", + "type": "spanner/duration" + }, { + "title": "KeyRange", + "type": "spanner/keyrange" + }, { + "title": "KeySet", + "type": "spanner/keyset" + }, { + "title": "Result", + "type": "spanner/result" + }, { + "title": "Timestamp", + "type": "spanner/timestamp" }] } diff --git a/docs/manifest.json b/docs/manifest.json index 811e7c8ef55c..14dbd2124e2a 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -132,6 +132,14 @@ "master" ] }, + { + "id": "cloud-spanner", + "name": "google/cloud-spanner", + "defaultService": "spanner/spannerclient", + "versions": [ + "master" + ] + }, { "id": "cloud-speech", "name": "google/cloud-speech", diff --git a/src/Core/Exception/AbortedException.php b/src/Core/Exception/AbortedException.php index 60cd19e02130..842088b1fb00 100644 --- a/src/Core/Exception/AbortedException.php +++ b/src/Core/Exception/AbortedException.php @@ -29,7 +29,7 @@ class AbortedException extends ServiceException */ public function getRetryDelay() { - $metadata = array_filter($this->options, function ($metadataItem) { + $metadata = array_filter($this->metadata, function ($metadataItem) { if (array_key_exists('retryDelay', $metadataItem)) { return true; } @@ -37,11 +37,13 @@ public function getRetryDelay() return false; }); - $delay = $metadata[0]['retryDelay']; - if (!isset($delay['seconds'])) { - $delay['seconds'] = 0; + if (count($metadata) === 0) { + return ['seconds' => 0, 'nanos' => 0]; } - return $delay; + return $metadata[0]['retryDelay'] + [ + 'seconds' => 0, + 'nanos' => 0 + ]; } } diff --git a/src/Core/Exception/ServiceException.php b/src/Core/Exception/ServiceException.php index 07838cf6a789..0efc81ec2caa 100644 --- a/src/Core/Exception/ServiceException.php +++ b/src/Core/Exception/ServiceException.php @@ -32,23 +32,24 @@ class ServiceException extends GoogleException /** * @var array */ - protected $options; + protected $metadata; /** * Handle previous exceptions differently here. * - * @param string $message - * @param int $code - * @param Exception $serviceException + * @param string $message + * @param int $code + * @param Exception $serviceException + * @param array $metadata [optional] Exception metadata. */ public function __construct( $message, $code = null, Exception $serviceException = null, - array $options = [] + array $metadata = [] ) { $this->serviceException = $serviceException; - $this->options = $options; + $this->metadata = $metadata; parent::__construct($message, $code); } @@ -72,4 +73,12 @@ public function getServiceException() { return $this->serviceException; } + + /** + * Get exception metadata. + */ + public function getMetadata() + { + return $this->metadata; + } } diff --git a/src/Core/GrpcRequestWrapper.php b/src/Core/GrpcRequestWrapper.php index e8fcd2fdb98f..ffe5066121ac 100644 --- a/src/Core/GrpcRequestWrapper.php +++ b/src/Core/GrpcRequestWrapper.php @@ -75,7 +75,8 @@ class GrpcRequestWrapper * @var array Map of error metadata types to RPC wrappers. */ private $metadataTypes = [ - 'google.rpc.retryinfo-bin' => \google\rpc\RetryInfo::class + 'google.rpc.retryinfo-bin' => \google\rpc\RetryInfo::class, + 'google.rpc.badrequest-bin' => \google\rpc\BadRequest::class ]; /** @@ -168,7 +169,8 @@ private function handleResponse($response) } if ($response instanceof Message) { - return $response->serialize($this->codec); + $res = $response->serialize($this->codec); + return $this->convertNulls($res); } if ($response instanceof OperationResponse) { @@ -192,7 +194,8 @@ private function handleStream(ServerStream $response) { try { foreach ($response->readAll() as $count => $result) { - yield $result->serialize($this->codec); + $res = $result->serialize($this->codec); + yield $this->convertNulls($res); } } catch (\Exception $ex) { throw $this->convertToGoogleException($ex); @@ -256,4 +259,23 @@ private function convertToGoogleException(ApiException $ex) return new $exception($ex->getMessage(), $ex->getCode(), $ex, $metadata); } + + /** + * Convert NullValue types to PHP null. + * + * @param array [ref] $result + * @return array + */ + private function convertNulls(array &$result) + { + foreach ($result as $key => $value) { + if (is_array($value) && array_key_exists('nullValue', $value) && $value['nullValue'][0] === null) { + $result[$key] = null; + } elseif (is_array($value)) { + $result[$key] = $this->convertNulls($value); + } + } + + return $result; + } } diff --git a/src/Core/GrpcTrait.php b/src/Core/GrpcTrait.php index 4fa5de20aa15..9867f26d8803 100644 --- a/src/Core/GrpcTrait.php +++ b/src/Core/GrpcTrait.php @@ -23,6 +23,7 @@ use Google\Auth\Cache\MemoryCacheItemPool; use Google\Cloud\Core\ArrayTrait; use Google\Cloud\Core\GrpcRequestWrapper; +use google\protobuf; /** * Provides shared functionality for gRPC service implementations. @@ -88,10 +89,16 @@ private function getGaxConfig($version) */ private function formatTimestampFromApi(array $timestamp) { + $timestamp += [ + 'seconds' => 0, + 'nanos' => 0 + ]; + $formattedTime = (new DateTime()) ->setTimeZone(new DateTimeZone('UTC')) ->setTimestamp($timestamp['seconds']) ->format('Y-m-d\TH:i:s'); + $timestamp['nanos'] = str_pad($timestamp['nanos'], 9, '0', STR_PAD_LEFT); return $formattedTime .= sprintf('.%sZ', rtrim($timestamp['nanos'], '0')); } @@ -171,6 +178,8 @@ private function formatValueForApi($value) return ['number_value' => $value]; case 'boolean': return ['bool_value' => $value]; + case 'NULL': + return ['null_value' => protobuf\NullValue::NULL_VALUE]; case 'array': if ($this->isAssoc($value)) { return ['struct_value' => $this->formatStructForApi($value)]; diff --git a/src/Core/LongRunning/LROTrait.php b/src/Core/LongRunning/LROTrait.php index 0b965357231e..2308054a343e 100644 --- a/src/Core/LongRunning/LROTrait.php +++ b/src/Core/LongRunning/LROTrait.php @@ -17,6 +17,8 @@ namespace Google\Cloud\Core\LongRunning; +use Google\Cloud\Core\Iterator\ItemIterator; +use Google\Cloud\Core\Iterator\PageIterator; use Google\Cloud\Core\LongRunning\LongRunningConnectionInterface; /** @@ -27,19 +29,97 @@ trait LROTrait { /** - * Create a Long Running Operation from an operation name. + * @var LongRunningConnectionInterface + */ + private $lroConnection; + + /** + * @var array + */ + private $lroCallables; + + /** + * @var string + */ + private $lroResource; + + /** + * Populate required LRO properties. * - * @param LongRunningConnectionInterface $connection The LRO connection - * @param string $operationName The name of the Operation. - * @param array $lroCallables A map of callables to normalize inputs and results. + * @param LongRunningConnectionInterface $lroConnection The LRO Connection. + * @param array $callablesMap An collection of form [(string) typeUrl, (callable) callable] + * providing a function to invoke when an operation completes. The + * callable Type should correspond to an expected value of + * operation.metadata.typeUrl. + * @param string $lroResource [optional] The resource for which operations + * may be listed. + */ + private function setLroProperties( + LongRunningConnectionInterface $lroConnection, + array $lroCallables, + $resource = null + ) { + $this->lroConnection = $lroConnection; + $this->lroCallables = $lroCallables; + $this->lroResource = $resource; + } + + /** + * Resume a Long Running Operation + * + * @param string $operationName The Long Running Operation name. + * @param array $info [optional] The operation data. * @return LongRunningOperation */ - private function lro(LongRunningConnectionInterface $connection, $operationName, array $lroCallables) + public function resumeOperation($operationName, array $info = []) { return new LongRunningOperation( - $connection, + $this->lroConnection, $operationName, - $lroCallables + $this->lroCallables, + $info + ); + } + + /** + * List long running operations. + * + * @param array $options [optional] { + * Configuration Options. + * + * @type string $name The name of the operation collection. + * @type string $filter The standard list filter. + * @type int $pageSize Maximum number of results to return per + * request. + * @type int $resultLimit Limit the number of results returned in total. + * **Defaults to** `0` (return all results). + * @type string $pageToken A previously-returned page token used to + * resume the loading of results from a specific point. + * } + * @return ItemIterator + */ + public function longRunningOperations(array $options = []) + { + if (is_null($this->lroResource)) { + throw new \BadMethodCallException('This service does list support listing operations.'); + } + + $resultLimit = $this->pluck('resultLimit', $options, false) ?: 0; + + $options['name'] = $this->lroResource .'/operations'; + + return new ItemIterator( + new PageIterator( + function (array $operation) { + return $this->resumeOperation($operation['name'], $operation); + }, + [$this->lroConnection, 'operations'], + $options, + [ + 'itemsKey' => 'operations', + 'resultLimit' => $resultLimit + ] + ) ); } } diff --git a/src/Core/LongRunning/LongRunningOperation.php b/src/Core/LongRunning/LongRunningOperation.php index 5828793f20c9..63732d91ba53 100644 --- a/src/Core/LongRunning/LongRunningOperation.php +++ b/src/Core/LongRunning/LongRunningOperation.php @@ -41,7 +41,7 @@ class LongRunningOperation /** * @var array */ - private $info; + private $info = []; /** * @var array|null @@ -66,15 +66,18 @@ class LongRunningOperation * providing a function to invoke when an operation completes. The * callable Type should correspond to an expected value of * operation.metadata.typeUrl. + * @param array $info [optional] The operation info. */ public function __construct( LongRunningConnectionInterface $connection, $name, - array $callablesMap + array $callablesMap, + array $info = [] ) { $this->connection = $connection; $this->name = $name; $this->callablesMap = $callablesMap; + $this->info = $info; } /** @@ -371,7 +374,8 @@ public function __debugInfo() return [ 'connection' => get_class($this->connection), 'name' => $this->name, - 'callablesMap' => array_keys($this->callablesMap) + 'callablesMap' => array_keys($this->callablesMap), + 'info' => $this->info ]; } } diff --git a/src/Core/LongRunning/OperationResponseTrait.php b/src/Core/LongRunning/OperationResponseTrait.php index 233f7a2122c7..7db8dfaf8855 100644 --- a/src/Core/LongRunning/OperationResponseTrait.php +++ b/src/Core/LongRunning/OperationResponseTrait.php @@ -27,6 +27,14 @@ */ trait OperationResponseTrait { + /** + * Convert a GAX OperationResponse object to an array. + * + * @param OperationResponse $operation The operation response + * @param CodecInterface $codec The codec to use for gRPC serialization/deserialization. + * @param array $lroMappers A list of mappers for deserializing operation results. + * @return array + */ private function operationToArray(OperationResponse $operation, CodecInterface $codec, array $lroMappers) { $response = $operation->getLastProtoResponse(); @@ -58,11 +66,12 @@ private function operationToArray(OperationResponse $operation, CodecInterface $ * * @param mixed $client A generated client with a `resumeOperation` method. * @param string $name The Operation name. + * @param string|null $method The method name. * @return OperationResponse */ - private function getOperationByName($client, $name) + private function getOperationByName($client, $name, $method = null) { - return $client->resumeOperation($name); + return $client->resumeOperation($name, $method); } /** diff --git a/src/Core/PhpArray.php b/src/Core/PhpArray.php index 7829afbce919..cf8a5fd360b7 100644 --- a/src/Core/PhpArray.php +++ b/src/Core/PhpArray.php @@ -159,7 +159,7 @@ protected function decodeMessage(Protobuf\Message $message, $data) protected function filterValue($value, Protobuf\Field $field) { if (trim($field->getReference(), '\\') === NullValue::class) { - return null; + return 0; } if ($value instanceof Protobuf\Message) { diff --git a/src/Logging/Connection/Grpc.php b/src/Logging/Connection/Grpc.php index cfa006bbb0f4..b5f00f8aeb94 100644 --- a/src/Logging/Connection/Grpc.php +++ b/src/Logging/Connection/Grpc.php @@ -90,17 +90,15 @@ class Grpc implements ConnectionInterface public function __construct(array $config = []) { $this->codec = new PhpArray([ - 'customFilters' => [ - 'timestamp' => function ($v) { - return $this->formatTimestampFromApi($v); - }, - 'severity' => function ($v) { - return Logger::getLogLevelMap()[$v]; - }, - 'outputVersionFormat' => function ($v) { - return self::$versionFormatMap[$v]; - } - ] + 'timestamp' => function ($v) { + return $this->formatTimestampFromApi($v); + }, + 'severity' => function ($v) { + return Logger::getLogLevelMap()[$v]; + }, + 'outputVersionFormat' => function ($v) { + return self::$versionFormatMap[$v]; + } ]); $config['codec'] = $this->codec; $this->setRequestWrapper(new GrpcRequestWrapper($config)); diff --git a/src/Spanner/Connection/ConnectionInterface.php b/src/Spanner/Connection/ConnectionInterface.php index 12de8323345a..cb6e1d5501ce 100644 --- a/src/Spanner/Connection/ConnectionInterface.php +++ b/src/Spanner/Connection/ConnectionInterface.php @@ -23,136 +23,141 @@ interface ConnectionInterface { /** - * @param array $args [optional] + * @param array $args */ - public function listConfigs(array $args = []); + public function listInstanceConfigs(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function getConfig(array $args = []); + public function getInstanceConfig(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function listInstances(array $args = []); + public function listInstances(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function getInstance(array $args = []); + public function getInstance(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function createInstance(array $args = []); + public function createInstance(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function updateInstance(array $args = []); + public function updateInstance(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function deleteInstance(array $args = []); + public function deleteInstance(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function getInstanceIamPolicy(array $args = []); + public function getInstanceIamPolicy(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function setInstanceIamPolicy(array $args = []); + public function setInstanceIamPolicy(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function testInstanceIamPermissions(array $args = []); + public function testInstanceIamPermissions(array $args); /** - * @param array $args [optional] + * @param array $args + */ + public function listDatabases(array $args); + + /** + * @param array $args */ - public function listDatabases(array $args = []); + public function createDatabase(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function createDatabase(array $args = []); + public function updateDatabaseDdl(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function updateDatabase(array $args = []); + public function dropDatabase(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function dropDatabase(array $args = []); + public function getDatabase(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function getDatabaseDDL(array $args = []); + public function getDatabaseDDL(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function getDatabaseIamPolicy(array $args = []); + public function getDatabaseIamPolicy(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function setDatabaseIamPolicy(array $args = []); + public function setDatabaseIamPolicy(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function testDatabaseIamPermissions(array $args = []); + public function testDatabaseIamPermissions(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function createSession(array $args = []); + public function createSession(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function getSession(array $args = []); + public function getSession(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function deleteSession(array $args = []); + public function deleteSession(array $args); /** - * @param array $args [optional] + * @param array $args * @return \Generator */ - public function executeStreamingSql(array $args = []); + public function executeStreamingSql(array $args); /** - * @param array $args [optional] + * @param array $args * @return \Generator */ - public function streamingRead(array $args = []); + public function streamingRead(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function beginTransaction(array $args = []); + public function beginTransaction(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function commit(array $args = []); + public function commit(array $args); /** - * @param array $args [optional] + * @param array $args */ - public function rollback(array $args = []); + public function rollback(array $args); /** * @param array $args diff --git a/src/Spanner/Connection/Grpc.php b/src/Spanner/Connection/Grpc.php index 532ea335599a..a02d4f179364 100644 --- a/src/Spanner/Connection/Grpc.php +++ b/src/Spanner/Connection/Grpc.php @@ -24,6 +24,7 @@ use Google\Cloud\Core\PhpArray; use Google\Cloud\Spanner\Admin\Database\V1\DatabaseAdminClient; use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; +use Google\Cloud\Spanner\Operation; use Google\Cloud\Spanner\SpannerClient as VeneerSpannerClient; use Google\Cloud\Spanner\V1\SpannerClient; use Google\GAX\ApiException; @@ -116,14 +117,12 @@ class Grpc implements ConnectionInterface public function __construct(array $config = []) { $this->codec = new PhpArray([ - 'customFilters' => [ - 'commitTimestamp' => function ($v) { - return $this->formatTimestampFromApi($v); - }, - 'readTimestamp' => function ($v) { - return $this->formatTimestampFromApi($v); - } - ] + 'commitTimestamp' => function ($v) { + return $this->formatTimestampFromApi($v); + }, + 'readTimestamp' => function ($v) { + return $this->formatTimestampFromApi($v); + } ]); $config['codec'] = $this->codec; @@ -141,9 +140,9 @@ public function __construct(array $config = []) } /** - * @param array $args [optional] + * @param array $args */ - public function listConfigs(array $args = []) + public function listInstanceConfigs(array $args) { return $this->send([$this->instanceAdminClient, 'listInstanceConfigs'], [ $this->pluck('projectId', $args), @@ -152,9 +151,9 @@ public function listConfigs(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function getConfig(array $args = []) + public function getInstanceConfig(array $args) { return $this->send([$this->instanceAdminClient, 'getInstanceConfig'], [ $this->pluck('name', $args), @@ -163,9 +162,9 @@ public function getConfig(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function listInstances(array $args = []) + public function listInstances(array $args) { return $this->send([$this->instanceAdminClient, 'listInstances'], [ $this->pluck('projectId', $args), @@ -174,9 +173,9 @@ public function listInstances(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function getInstance(array $args = []) + public function getInstance(array $args) { return $this->send([$this->instanceAdminClient, 'getInstance'], [ $this->pluck('name', $args), @@ -185,9 +184,9 @@ public function getInstance(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function createInstance(array $args = []) + public function createInstance(array $args) { $instance = $this->instanceObject($args, true); $res = $this->send([$this->instanceAdminClient, 'createInstance'], [ @@ -201,13 +200,13 @@ public function createInstance(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function updateInstance(array $args = []) + public function updateInstance(array $args) { $instanceObject = $this->instanceObject($args); - $mask = array_keys($instanceObject->serialize(new PhpArray(['useCamelCase' => false]))); + $mask = array_keys($instanceObject->serialize(new PhpArray([], false))); $fieldMask = (new protobuf\FieldMask())->deserialize(['paths' => $mask], $this->codec); @@ -221,9 +220,9 @@ public function updateInstance(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function deleteInstance(array $args = []) + public function deleteInstance(array $args) { return $this->send([$this->instanceAdminClient, 'deleteInstance'], [ $this->pluck('name', $args), @@ -232,9 +231,9 @@ public function deleteInstance(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function getInstanceIamPolicy(array $args = []) + public function getInstanceIamPolicy(array $args) { return $this->send([$this->instanceAdminClient, 'getIamPolicy'], [ $this->pluck('resource', $args), @@ -243,9 +242,9 @@ public function getInstanceIamPolicy(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function setInstanceIamPolicy(array $args = []) + public function setInstanceIamPolicy(array $args) { return $this->send([$this->instanceAdminClient, 'setIamPolicy'], [ $this->pluck('resource', $args), @@ -255,9 +254,9 @@ public function setInstanceIamPolicy(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function testInstanceIamPermissions(array $args = []) + public function testInstanceIamPermissions(array $args) { return $this->send([$this->instanceAdminClient, 'testIamPermissions'], [ $this->pluck('resource', $args), @@ -267,9 +266,9 @@ public function testInstanceIamPermissions(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function listDatabases(array $args = []) + public function listDatabases(array $args) { return $this->send([$this->databaseAdminClient, 'listDatabases'], [ $this->pluck('instance', $args), @@ -278,9 +277,9 @@ public function listDatabases(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function createDatabase(array $args = []) + public function createDatabase(array $args) { $res = $this->send([$this->databaseAdminClient, 'createDatabase'], [ $this->pluck('instance', $args), @@ -293,9 +292,9 @@ public function createDatabase(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function updateDatabase(array $args = []) + public function updateDatabaseDdl(array $args) { $res = $this->send([$this->databaseAdminClient, 'updateDatabaseDdl'], [ $this->pluck('name', $args), @@ -307,9 +306,9 @@ public function updateDatabase(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function dropDatabase(array $args = []) + public function dropDatabase(array $args) { return $this->send([$this->databaseAdminClient, 'dropDatabase'], [ $this->pluck('name', $args), @@ -318,9 +317,20 @@ public function dropDatabase(array $args = []) } /** - * @param array $args [optional] + * @param array $args + */ + public function getDatabase(array $args) + { + return $this->send([$this->databaseAdminClient, 'getDatabase'], [ + $this->pluck('name', $args), + $args + ]); + } + + /** + * @param array $args */ - public function getDatabaseDDL(array $args = []) + public function getDatabaseDDL(array $args) { return $this->send([$this->databaseAdminClient, 'getDatabaseDDL'], [ $this->pluck('name', $args), @@ -329,9 +339,9 @@ public function getDatabaseDDL(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function getDatabaseIamPolicy(array $args = []) + public function getDatabaseIamPolicy(array $args) { return $this->send([$this->databaseAdminClient, 'getIamPolicy'], [ $this->pluck('resource', $args), @@ -340,9 +350,9 @@ public function getDatabaseIamPolicy(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function setDatabaseIamPolicy(array $args = []) + public function setDatabaseIamPolicy(array $args) { return $this->send([$this->databaseAdminClient, 'setIamPolicy'], [ $this->pluck('resource', $args), @@ -352,9 +362,9 @@ public function setDatabaseIamPolicy(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function testDatabaseIamPermissions(array $args = []) + public function testDatabaseIamPermissions(array $args) { return $this->send([$this->databaseAdminClient, 'testIamPermissions'], [ $this->pluck('resource', $args), @@ -364,9 +374,9 @@ public function testDatabaseIamPermissions(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function createSession(array $args = []) + public function createSession(array $args) { return $this->send([$this->spannerClient, 'createSession'], [ $this->pluck('database', $args), @@ -375,9 +385,9 @@ public function createSession(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function getSession(array $args = []) + public function getSession(array $args) { return $this->send([$this->spannerClient, 'getSession'], [ $this->pluck('name', $args), @@ -386,9 +396,9 @@ public function getSession(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function deleteSession(array $args = []) + public function deleteSession(array $args) { return $this->send([$this->spannerClient, 'deleteSession'], [ $this->pluck('name', $args), @@ -397,20 +407,24 @@ public function deleteSession(array $args = []) } /** - * @param array $args [optional] + * @param array $args * @return \Generator */ - public function executeStreamingSql(array $args = []) + public function executeStreamingSql(array $args) { $params = $this->pluck('params', $args); if ($params) { + // print_r($this->formatStructForApi($params)); $args['params'] = (new protobuf\Struct) ->deserialize($this->formatStructForApi($params), $this->codec); + // var_dump($args['params']);exit; } - foreach ($args['paramTypes'] as $key => $param) { - $args['paramTypes'][$key] = (new Type) - ->deserialize($param, $this->codec); + if (isset($args['paramTypes']) && is_array($args['paramTypes'])) { + foreach ($args['paramTypes'] as $key => $param) { + $args['paramTypes'][$key] = (new Type) + ->deserialize($param, $this->codec); + } } $args['transaction'] = $this->createTransactionSelector($args); @@ -423,10 +437,10 @@ public function executeStreamingSql(array $args = []) } /** - * @param array $args [optional] + * @param array $args * @return \Generator */ - public function streamingRead(array $args = []) + public function streamingRead(array $args) { $keySet = $this->pluck('keySet', $args); $keySet = (new KeySet) @@ -444,15 +458,15 @@ public function streamingRead(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function beginTransaction(array $args = []) + public function beginTransaction(array $args) { $options = new TransactionOptions; - if (isset($args['transactionOptions']['readOnly'])) { - $ro = $args['transactionOptions']['readOnly']; - + $transactionOptions = $this->pluck('transactionOptions', $args); + if (isset($transactionOptions['readOnly'])) { + $ro = $transactionOptions['readOnly']; if (isset($ro['minReadTimestamp'])) { $ro['minReadTimestamp'] = $this->formatTimestampForApi($ro['minReadTimestamp']); } @@ -478,9 +492,9 @@ public function beginTransaction(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function commit(array $args = []) + public function commit(array $args) { $inputMutations = $this->pluck('mutations', $args); @@ -491,7 +505,7 @@ public function commit(array $args = []) $data = $mutation[$type]; switch ($type) { - case 'delete': + case Operation::OP_DELETE: if (isset($data['keySet'])) { $data['keySet'] = $this->formatKeySet($data['keySet']); } @@ -533,9 +547,9 @@ public function commit(array $args = []) } /** - * @param array $args [optional] + * @param array $args */ - public function rollback(array $args = []) + public function rollback(array $args) { return $this->send([$this->spannerClient, 'rollback'], [ $this->pluck('session', $args), @@ -564,7 +578,10 @@ public function cancelOperation(array $args) $name = $this->pluck('name', $args); $method = $this->pluck('method', $args); - $operation = $this->getOperationByNameAndMethod($name, $method); + $operation = $this->getOperationByName($this->databaseAdminClient, $name, $method); + $operation->cancel(); + + return $this->operationToArray($operation, $this->codec, $this->lroResponseMappers); } /** @@ -575,7 +592,10 @@ public function deleteOperation(array $args) $name = $this->pluck('name', $args); $method = $this->pluck('method', $args); - $operation = $this->getOperationByNameAndMethod($name, $method); + $operation = $this->getOperationByName($this->databaseAdminClient, $name, $method); + $operation->delete(); + + return $this->operationToArray($operation, $this->codec, $this->lroResponseMappers); } /** @@ -583,8 +603,16 @@ public function deleteOperation(array $args) */ public function listOperations(array $args) { - $name = $this->pluck('name', $args); - $method = $this->pluck('method', $args); + $name = $this->pluck('name', $args, false) ?: ''; + $filter = $this->pluck('filter', $args, false) ?: ''; + + $client = $this->databaseAdminClient->getOperationsClient(); + + return $this->send([$client, 'listOperations'], [ + $name, + $filter, + $args + ]); } /** @@ -593,8 +621,9 @@ public function listOperations(array $args) */ private function formatKeySet(array $keySet) { - if (isset($keySet['keys'])) { - $keySet['keys'] = $this->formatListForApi($keySet['keys']); + $keys = $this->pluck('keys', $keySet, false); + if ($keys) { + $keySet['keys'] = $this->formatListForApi($keys); } if (isset($keySet['ranges'])) { @@ -636,13 +665,18 @@ private function createTransactionSelector(array &$args) */ private function instanceObject(array &$args, $required = false) { + $labels = null; + if (isset($args['labels'])) { + $labels = $this->formatLabelsForApi($this->pluck('labels', $args, $required)); + } + return (new Instance())->deserialize(array_filter([ 'name' => $this->pluck('name', $args, $required), 'config' => $this->pluck('config', $args, $required), 'displayName' => $this->pluck('displayName', $args, $required), 'nodeCount' => $this->pluck('nodeCount', $args, $required), 'state' => $this->pluck('state', $args, $required), - 'labels' => $this->formatLabelsForApi($this->pluck('labels', $args, $required)) + 'labels' => $labels ]), $this->codec); } } diff --git a/src/Spanner/Database.php b/src/Spanner/Database.php index 27a5f477fad3..e0099b7bb129 100644 --- a/src/Spanner/Database.php +++ b/src/Spanner/Database.php @@ -20,18 +20,22 @@ use Google\Cloud\Core\Exception\AbortedException; use Google\Cloud\Core\Exception\NotFoundException; use Google\Cloud\Core\Iam\Iam; -use Google\Cloud\Core\LongRunning\LongRunningOperation; use Google\Cloud\Core\LongRunning\LROTrait; use Google\Cloud\Core\LongRunning\LongRunningConnectionInterface; +use Google\Cloud\Core\LongRunning\LongRunningOperation; use Google\Cloud\Core\Retry; +use Google\Cloud\Spanner\Admin\Database\V1\DatabaseAdminClient; +use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Connection\IamDatabase; use Google\Cloud\Spanner\Session\Session; use Google\Cloud\Spanner\Session\SessionPoolInterface; +use Google\Cloud\Spanner\Transaction; use Google\Cloud\Spanner\V1\SpannerClient as GrpcSpannerClient; +use Google\GAX\ValidationException; /** - * Represents a Google Cloud Spanner Database. + * Represents a Cloud Spanner Database. * * Example: * ``` @@ -51,6 +55,41 @@ * $instance = $spanner->instance('my-instance'); * $database = $instance->database('my-database'); * ``` + * + * @method resumeOperation() { + * Resume a Long Running Operation + * + * Example: + * ``` + * $operation = $database->resumeOperation($operationName); + * ``` + * + * @param string $operationName The Long Running Operation name. + * @param array $info [optional] The operation data. + * @return LongRunningOperation + * } + * @method longRunningOperations() { + * List long running operations. + * + * Example: + * ``` + * $operations = $database->longRunningOperations(); + * ``` + * + * @param array $options [optional] { + * Configuration Options. + * + * @type string $name The name of the operation collection. + * @type string $filter The standard list filter. + * @type int $pageSize Maximum number of results to return per + * request. + * @type int $resultLimit Limit the number of results returned in total. + * **Defaults to** `0` (return all results). + * @type string $pageToken A previously-returned page token used to + * resume the loading of results from a specific point. + * } + * @return ItemIterator + * } */ class Database { @@ -69,11 +108,6 @@ class Database */ private $instance; - /** - * @var LongRunningConnectionInterface - */ - private $lroConnection; - /** * @var Operation */ @@ -89,6 +123,11 @@ class Database */ private $name; + /** + * @var array + */ + private $info; + /** * @var Iam */ @@ -108,12 +147,12 @@ class Database * Create an object representing a Database. * * @param ConnectionInterface $connection The connection to the - * Google Cloud Spanner Admin API. + * Cloud Spanner Admin API. * @param Instance $instance The instance in which the database exists. * @param LongRunningConnectionInterface $lroConnection An implementation * mapping to methods which handle LRO resolution in the service. * @param string $projectId The project ID. - * @param string $name The database name. + * @param string $name The database name or ID. * @param SessionPoolInterface $sessionPool [optional] The session pool * implementation. * @param bool $returnInt64AsObject [optional If true, 64 bit integers will @@ -132,20 +171,20 @@ public function __construct( ) { $this->connection = $connection; $this->instance = $instance; - $this->lroConnection = $lroConnection; - $this->lroCallables = $lroCallables; $this->projectId = $projectId; - $this->name = $name; + $this->name = $this->fullyQualifiedDatabaseName($name); $this->sessionPool = $sessionPool; $this->operation = new Operation($connection, $returnInt64AsObject); if ($this->sessionPool) { $this->sessionPool->setDatabase($this); } + + $this->setLroProperties($lroConnection, $lroCallables, $this->name); } /** - * Return the simple database name. + * Return the fully-qualified database name. * * Example: * ``` @@ -159,6 +198,48 @@ public function name() return $this->name; } + /** + * Get the database info + * + * Example: + * ``` + * $info = $database->info(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.database.v1#google.spanner.admin.database.v1.Database Database + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] Configuration options. + * @return array + */ + public function info(array $options = []) + { + return $this->info ?: $this->reload($options); + } + + /** + * Reload the database info from the Cloud Spanner API. + * + * Example: + * ``` + * $info = $database->reload(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.database.v1#google.spanner.admin.database.v1.Database Database + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] Configuration options. + * @return array + */ + public function reload(array $options = []) + { + return $this->info = $this->connection->getDatabase([ + 'name' => $this->name + ] + $options); + } + /** * Check if the database exists. * @@ -179,7 +260,7 @@ public function name() public function exists(array $options = []) { try { - $this->ddl($options); + $this->reload($options); } catch (NotFoundException $e) { return false; } @@ -187,6 +268,42 @@ public function exists(array $options = []) return true; } + /** + * Create a new Cloud Spanner database. + * + * Example: + * ``` + * $operation = $database->create(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.admin.database.v1#createdatabaserequest CreateDatabaseRequest + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] { + * Configuration Options + * + * @type array $statements Additional DDL statements. + * } + * @return LongRunningOperation + */ + public function create(array $options = []) + { + $options += [ + 'statements' => [], + ]; + + $statement = sprintf('CREATE DATABASE `%s`', DatabaseAdminClient::parseDatabaseFromDatabaseName($this->name)); + + $operation = $this->connection->createDatabase([ + 'instance' => $this->instance->name(), + 'createStatement' => $statement, + 'extraStatements' => $options['statements'] + ]); + + return $this->resumeOperation($operation['name'], $operation); + } + /** * Update the Database schema by running a SQL statement. * @@ -249,12 +366,12 @@ public function updateDdl($statement, array $options = []) */ public function updateDdlBatch(array $statements, array $options = []) { - $operation = $this->connection->updateDatabase($options + [ - 'name' => $this->fullyQualifiedDatabaseName(), + $operation = $this->connection->updateDatabaseDdl($options + [ + 'name' => $this->name, 'statements' => $statements, ]); - return $this->lro($this->lroConnection, $operation['name'], $this->lroCallables); + return $this->resumeOperation($operation['name'], $operation); } /** @@ -277,7 +394,7 @@ public function updateDdlBatch(array $statements, array $options = []) public function drop(array $options = []) { $this->connection->dropDatabase($options + [ - 'name' => $this->fullyQualifiedDatabaseName() + 'name' => $this->name ]); } @@ -301,7 +418,7 @@ public function drop(array $options = []) public function ddl(array $options = []) { $ddl = $this->connection->getDatabaseDDL($options + [ - 'name' => $this->fullyQualifiedDatabaseName() + 'name' => $this->name ]); if (isset($ddl['statements'])) { @@ -326,7 +443,7 @@ public function iam() if (!$this->iam) { $this->iam = new Iam( new IamDatabase($this->connection), - $this->fullyQualifiedDatabaseName() + $this->name ); } @@ -377,25 +494,82 @@ public function iam() * timestamp. * @type Duration $exactStaleness Represents a number of seconds. Executes * all reads at a timestamp that is $exactStaleness old. + * @type Timestamp $minReadTimestamp Executes all reads at a + * timestamp >= min_read_timestamp. Only available when + * `$options.singleUse` is true. + * @type Duration $maxStaleness Read data at a timestamp >= NOW - max_staleness + * seconds. Guarantees that all writes that have committed more + * than the specified number of seconds ago are visible. Only + * available when `$options.singleUse` is true. + * @type bool $singleUse If true, a Transaction ID will not be allocated + * up front. Instead, the transaction will be considered + * "single-use", and may be used for only a single operation. + * **Defaults to** `false`. * } * @return Snapshot * @codingStandardsIgnoreEnd */ public function snapshot(array $options = []) { - // These are only available in single-use transactions. - if (isset($options['maxStaleness']) || isset($options['minReadTimestamp'])) { - throw new \BadMethodCallException( - 'maxStaleness and minReadTimestamp are only available in single-use transactions.' - ); - } + $options += [ + 'singleUse' => false + ]; - $transactionOptions = $this->configureSnapshotOptions($options); + $options['transactionOptions'] = $this->configureSnapshotOptions($options); $session = $this->selectSession(SessionPoolInterface::CONTEXT_READ); try { - return $this->operation->snapshot($session, $transactionOptions); + return $this->operation->snapshot($session, $options); + } finally { + $session->setExpiration(); + } + } + + /** + * Create and return a new read/write Transaction. + * + * When manually using a Transaction, it is advised that retry logic be + * implemented to reapply all operations when an instance of + * {@see Google\Cloud\Core\Exception\AbortedException} is thrown. + * + * If you wish Google Cloud PHP to handle retry logic for you (recommended + * for most cases), use {@see Google\Cloud\Spanner\Database::runTransaction()}. + * + * Please note that once a transaction reads data, it will lock the read + * data, preventing other users from modifying that data. For this reason, + * it is important that every transaction commits or rolls back as early as + * possible. Do not hold transactions open longer than necessary. + * + * Example: + * ``` + * $transaction = $database->transaction(); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest + * @see https://cloud.google.com/spanner/docs/transactions Transactions + * @codingStandardsIgnoreEnd + * + * @param array $options [optional] { + * Configuration Options. + * + * @type bool $singleUse If true, a Transaction ID will not be allocated + * up front. Instead, the transaction will be considered + * "single-use", and may be used for only a single operation. + * **Defaults to** `false`. + * } + * @return Transaction + */ + public function transaction(array $options = []) + { + // There isn't anything configurable here. + $options['transactionOptions'] = $this->configureTransactionOptions(); + + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); + + try { + return $this->operation->transaction($session, $options); } finally { $session->setExpiration(); } @@ -419,11 +593,10 @@ public function snapshot(array $options = []) * it is important that every transaction commits or rolls back as early as * possible. Do not hold transactions open longer than necessary. * - * If you have an active transaction which was obtained from elsewhere, you - * can provide it to this method and gain the benefits of managed retry by - * setting `$options.transaction` to your {@see Google\Cloud\Spanner\Transaction} - * instance. Please note that in this case, it is important that ALL reads - * and mutations MUST be performed within the runTransaction callable. + * If a callable finishes executing without invoking + * {@see Google\Cloud\Spanner\Transaction::commit()} or + * {@see Google\Cloud\Spanner\Transaction::rollback()}, the transaction will + * automatically be rolled back and `RuntimeException` thrown. * * Example: * ``` @@ -462,17 +635,20 @@ public function snapshot(array $options = []) * * @type int $maxRetries The number of times to attempt to apply the * operation before failing. **Defaults to ** `3`. - * @type Transaction $transaction If provided, the transaction will be - * passed to the callable instead of attempting to begin a new - * transaction. + * @type bool $singleUse If true, a Transaction ID will not be allocated + * up front. Instead, the transaction will be considered + * "single-use", and may be used for only a single operation. Note + * that in a single-use transaction, only a single operation may + * be executed, and rollback is not available. **Defaults to** + * `false`. * } * @return mixed The return value of `$operation`. + * @throws RuntimeException */ public function runTransaction(callable $operation, array $options = []) { $options += [ 'maxRetries' => self::MAX_RETRIES, - 'transaction' => null ]; // There isn't anything configurable here. @@ -482,13 +658,7 @@ public function runTransaction(callable $operation, array $options = []) $attempt = 0; $startTransactionFn = function ($session, $options) use (&$attempt) { - if ($attempt === 0 && $options['transaction'] instanceof Transaction) { - $transaction = $options['transaction']; - } elseif ($attempt === 0 && $options['transaction']) { - throw new \InvalidArgumentException('Given transaction must be an instance of Transaction.'); - } else { - $transaction = $this->operation->transaction($session, $options); - } + $transaction = $this->operation->transaction($session, $options); $attempt++; return $transaction; @@ -503,63 +673,26 @@ public function runTransaction(callable $operation, array $options = []) time_nanosleep($delay['seconds'], $delay['nanos']); }; - $commitFn = function ($operation, $session, $options) use ($startTransactionFn) { + $transactionFn = function ($operation, $session, $options) use ($startTransactionFn) { $transaction = call_user_func_array($startTransactionFn, [ $session, $options ]); - return call_user_func($operation, $transaction); - }; - - $retry = new Retry($options['maxRetries'], $delayFn); + $res = call_user_func($operation, $transaction); - try { - return $retry->execute($commitFn, [$operation, $session, $options]); - } finally { - $session->setExpiration(); - } - } + if ($transaction->state() === Transaction::STATE_ACTIVE) { + $transaction->rollback($options); + throw new \RuntimeException('Transactions must be rolled back or committed.'); + } - /** - * Create and return a new read/write Transaction. - * - * When manually using a Transaction, it is advised that retry logic be - * implemented to reapply all operations when an instance of - * {@see Google\Cloud\Core\Exception\AbortedException} is thrown. - * - * If you wish Google Cloud PHP to handle retry logic for you (recommended - * for most cases), use {@see Google\Cloud\Spanner\Database::runTransaction()}. - * - * Please note that once a transaction reads data, it will lock the read - * data, preventing other users from modifying that data. For this reason, - * it is important that every transaction commits or rolls back as early as - * possible. Do not hold transactions open longer than necessary. - * - * Example: - * ``` - * $transaction = $database->transaction(); - * ``` - * - * @codingStandardsIgnoreStart - * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest - * @see https://cloud.google.com/spanner/docs/transactions Transactions - * @codingStandardsIgnoreEnd - * - * @param array $options [optional] Configuration Options. - * @return Transaction - */ - public function transaction(array $options = []) - { - // There isn't anything configurable here. - $options['transactionOptions'] = [ - 'readWrite' => [] - ]; + return $res; + }; - $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); + $retry = new Retry($options['maxRetries'], $delayFn); try { - return $this->operation->transaction($session, $options); + return $retry->execute($transactionFn, [$operation, $session, $options]); } finally { $session->setExpiration(); } @@ -629,23 +762,15 @@ public function insertBatch($table, array $dataSet, array $options = []) $mutations[] = $this->operation->mutation(Operation::OP_INSERT, $table, $data); } - $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); - - $options['singleUseTransaction'] = $this->configureTransactionOptions(); - - try { - return $this->operation->commit($session, $mutations, $options); - } finally { - $session->setExpiration(); - } + return $this->commitInSingleUseTransaction($mutations, $options); } /** * Update a row. * - * Only data which you wish to update need be included. You must provide - * enough information for the API to determine which row should be modified. - * In most cases, this means providing values for the Primary Key fields. + * Only data which you wish to update need be included. The list of columns + * must contain enough columns to allow Cloud Spanner to derive values for + * all primary key columns in the row to be modified. * * Mutations are committed in a single-use transaction. * @@ -674,9 +799,9 @@ public function update($table, array $data, array $options = []) /** * Update multiple rows. * - * Only data which you wish to update need be included. You must provide - * enough information for the API to determine which row should be modified. - * In most cases, this means providing values for the Primary Key fields. + * Only data which you wish to update need be included. The list of columns + * must contain enough columns to allow Cloud Spanner to derive values for + * all primary key columns in the row(s) to be modified. * * Mutations are committed in a single-use transaction. * @@ -709,15 +834,7 @@ public function updateBatch($table, array $dataSet, array $options = []) $mutations[] = $this->operation->mutation(Operation::OP_UPDATE, $table, $data); } - $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); - - $options['singleUseTransaction'] = $this->configureTransactionOptions(); - - try { - return $this->operation->commit($session, $mutations, $options); - } finally { - $session->setExpiration(); - } + return $this->commitInSingleUseTransaction($mutations, $options); } /** @@ -792,23 +909,15 @@ public function insertOrUpdateBatch($table, array $dataSet, array $options = []) $mutations[] = $this->operation->mutation(Operation::OP_INSERT_OR_UPDATE, $table, $data); } - $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); - - $options['singleUseTransaction'] = $this->configureTransactionOptions(); - - try { - return $this->operation->commit($session, $mutations, $options); - } finally { - $session->setExpiration(); - } + return $this->commitInSingleUseTransaction($mutations, $options); } /** * Replace a row. * - * Provide data for the entire row. Google Cloud Spanner will attempt to - * find a record matching the Primary Key, and will replace the entire row. - * If a matching row is not found, it will be inserted. + * Provide data for the entire row. Cloud Spanner will attempt to find a + * record matching the Primary Key, and will replace the entire row. If a + * matching row is not found, it will be inserted. * * Mutations are committed in a single-use transaction. * @@ -838,9 +947,9 @@ public function replace($table, array $data, array $options = []) /** * Replace multiple rows. * - * Provide data for the entire row. Google Cloud Spanner will attempt to - * find a record matching the Primary Key, and will replace the entire row. - * If a matching row is not found, it will be inserted. + * Provide data for the entire row. Cloud Spanner will attempt to find a + * record matching the Primary Key, and will replace the entire row. If a + * matching row is not found, it will be inserted. * * Mutations are committed in a single-use transaction. * @@ -875,15 +984,7 @@ public function replaceBatch($table, array $dataSet, array $options = []) $mutations[] = $this->operation->mutation(Operation::OP_REPLACE, $table, $data); } - $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); - - $options['singleUseTransaction'] = $this->configureTransactionOptions(); - - try { - return $this->operation->commit($session, $mutations, $options); - } finally { - $session->setExpiration(); - } + return $this->commitInSingleUseTransaction($mutations, $options); } /** @@ -915,15 +1016,7 @@ public function delete($table, KeySet $keySet, array $options = []) { $mutations = [$this->operation->deleteMutation($table, $keySet)]; - $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); - - $options['singleUseTransaction'] = $this->configureTransactionOptions(); - - try { - return $this->operation->commit($session, $mutations, $options); - } finally { - $session->setExpiration(); - } + return $this->commitInSingleUseTransaction($mutations, $options); } /** @@ -948,7 +1041,8 @@ public function delete($table, KeySet $keySet, array $options = []) * 'parameters' => [ * 'postId' => 1337 * ], - * 'begin' => true + * 'begin' => true, + * 'transactionType' => SessionPoolInterface::CONTEXT_READ * ]); * * $result->rows()->current(); @@ -979,17 +1073,25 @@ public function delete($table, KeySet $keySet, array $options = []) * @param string $sql The query string to execute. * @param array $options [optional] { * Configuration Options. - * * See [TransactionOptions](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#google.spanner.v1.TransactionOptions) - * for detailed description of available transaction options. - * - * Please note that only one of `$strong`, `$minReadTimestamp`, + * for detailed description of available transaction options. Please + * note that only one of `$strong`, `$minReadTimestamp`, * `$maxStaleness`, `$readTimestamp` or `$exactStaleness` may be set in * a request. * * @type array $parameters A key/value array of Query Parameters, where * the key is represented in the query string prefixed by a `@` * symbol. + * @type array $types A key/value array of Query Parameter types. + * Generally, Google Cloud PHP can infer types. Explicit type + * definitions are only necessary for null parameter values. + * Accepted values are defined as constants on + * {@see Google\Cloud\Spanner\ValueMapper}, and are as follows: + * `ValueMapper::TYPE_BOOL`, `ValueMapper::TYPE_INT64`, + * `ValueMapper::TYPE_FLOAT64`, `ValueMapper::TYPE_TIMESTAMP`, + * `ValueMapper::TYPE_DATE`, `ValueMapper::TYPE_STRING`, + * `ValueMapper::TYPE_BYTES`, `ValueMapper::TYPE_ARRAY` and + * `ValueMapper::TYPE_STRUCT`. * @type bool $returnReadTimestamp If true, the Cloud Spanner-selected * read timestamp is included in the Transaction message that * describes the transaction. @@ -1011,8 +1113,8 @@ public function delete($table, KeySet $keySet, array $options = []) * @type string $transactionType One of `SessionPoolInterface::CONTEXT_READ` * or `SessionPoolInterface::CONTEXT_READWRITE`. If read/write is * chosen, any snapshot options will be disregarded. If `$begin` - * is false, this option will be ignored. **Defaults to** - * `SessionPoolInterface::CONTEXT_READ`. + * is false, transaction type MUST be `SessionPoolInterface::CONTEXT_READ`. + * **Defaults to** `SessionPoolInterface::CONTEXT_READ`. * } * @codingStandardsIgnoreEnd * @return Result @@ -1059,7 +1161,8 @@ public function execute($sql, array $options = []) * $columns = ['ID', 'title', 'content']; * * $result = $database->read('Posts', $keySet, $columns, [ - * 'begin' => true + * 'begin' => true, + * 'transactionType' => SessionPoolInterface::CONTEXT_READ * ]); * * $result->rows()->current(); @@ -1102,7 +1205,6 @@ public function execute($sql, array $options = []) * a request. * * @type string $index The name of an index on the table. - * @type int $offset The number of rows to offset results by. * @type int $limit The number of results to return. * @type bool $returnReadTimestamp If true, the Cloud Spanner-selected * read timestamp is included in the Transaction message that @@ -1125,8 +1227,8 @@ public function execute($sql, array $options = []) * @type string $transactionType One of `SessionPoolInterface::CONTEXT_READ` * or `SessionPoolInterface::CONTEXT_READWRITE`. If read/write is * chosen, any snapshot options will be disregarded. If `$begin` - * is false, this option will be ignored. **Defaults to** - * `SessionPoolInterface::CONTEXT_READ`. + * is false, transaction type MUST be `SessionPoolInterface::CONTEXT_READ`. + * **Defaults to** `SessionPoolInterface::CONTEXT_READ`. * } * @codingStandardsIgnoreEnd * @return Result @@ -1211,11 +1313,7 @@ public function __destruct() public function createSession(array $options = []) { $res = $this->connection->createSession($options + [ - 'database' => GrpcSpannerClient::formatDatabaseName( - $this->projectId, - $this->instance->name(), - $this->name - ) + 'database' => $this->name ]); return $this->session($res['name']); @@ -1297,17 +1395,34 @@ private function selectSession($context = SessionPoolInterface::CONTEXT_READ) } } + private function commitInSingleUseTransaction(array $mutations, array $options = []) + { + $options['mutations'] = $mutations; + + return $this->runTransaction(function (Transaction $t) use ($options) { + return $t->commit($options); + }, [ + 'singleUse' => true + ]); + } + /** * Convert the simple database name to a fully qualified name. * * @return string */ - private function fullyQualifiedDatabaseName() + private function fullyQualifiedDatabaseName($name) { - return GrpcSpannerClient::formatDatabaseName( - $this->projectId, - $this->instance->name(), - $this->name - ); + $instance = InstanceAdminClient::parseInstanceFromInstanceName($this->instance->name()); + + try { + return GrpcSpannerClient::formatDatabaseName( + $this->projectId, + $instance, + $name + ); + } catch (ValidationException $e) { + return $name; + } } } diff --git a/src/Spanner/Date.php b/src/Spanner/Date.php index 8822c2bd9f7a..713ae879e06d 100644 --- a/src/Spanner/Date.php +++ b/src/Spanner/Date.php @@ -52,6 +52,27 @@ public function __construct(\DateTimeInterface $value) $this->value = $value; } + /** + * Create a Date from integer or string values. + * + * Example: + * ``` + * $date = Date::createFromValues(1995, 02, 04); + * ``` + * + * @param int|string $year The year. + * @param int|string $month The month (as a number). + * @param int|string $day The day of the month. + * @return Date + */ + public static function createFromValues($year, $month, $day) + { + $value = sprintf('%s-%s-%s', $year, $month, $day); + $dt = \DateTimeImmutable::createFromFormat(self::FORMAT, $value); + + return new static($dt); + } + /** * Get the underlying `\DateTimeInterface` implementation. * diff --git a/src/Spanner/Instance.php b/src/Spanner/Instance.php index 30746a269879..2c9bfa5dfe03 100644 --- a/src/Spanner/Instance.php +++ b/src/Spanner/Instance.php @@ -22,18 +22,19 @@ use Google\Cloud\Core\Iam\Iam; use Google\Cloud\Core\Iterator\ItemIterator; use Google\Cloud\Core\Iterator\PageIterator; -use Google\Cloud\Core\LongRunning\LongRunningOperation; use Google\Cloud\Core\LongRunning\LROTrait; use Google\Cloud\Core\LongRunning\LongRunningConnectionInterface; +use Google\Cloud\Core\LongRunning\LongRunningOperation; use Google\Cloud\Spanner\Admin\Database\V1\DatabaseAdminClient; use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Connection\IamInstance; use Google\Cloud\Spanner\Session\SessionPoolInterface; +use Google\GAX\ValidationException; use google\spanner\admin\instance\v1\Instance\State; /** - * Represents a Google Cloud Spanner instance + * Represents a Cloud Spanner instance * * Example: * ``` @@ -43,6 +44,41 @@ * * $instance = $spanner->instance('my-instance'); * ``` + * + * @method resumeOperation() { + * Resume a Long Running Operation + * + * Example: + * ``` + * $operation = $instance->resumeOperation($operationName); + * ``` + * + * @param string $operationName The Long Running Operation name. + * @param array $info [optional] The operation data. + * @return LongRunningOperation + * } + * @method longRunningOperations() { + * List long running operations. + * + * Example: + * ``` + * $operations = $instance->longRunningOperations(); + * ``` + * + * @param array $options [optional] { + * Configuration Options. + * + * @type string $name The name of the operation collection. + * @type string $filter The standard list filter. + * @type int $pageSize Maximum number of results to return per + * request. + * @type int $resultLimit Limit the number of results returned in total. + * **Defaults to** `0` (return all results). + * @type string $pageToken A previously-returned page token used to + * resume the loading of results from a specific point. + * } + * @return ItemIterator + * } */ class Instance { @@ -52,21 +88,13 @@ class Instance const STATE_READY = State::READY; const STATE_CREATING = State::CREATING; + const DEFAULT_NODE_COUNT = 1; + /** * @var ConnectionInterface */ private $connection; - /** - * @var LongRunningConnectionInterface - */ - private $lroConnection; - - /** - * @var array - */ - private $lroCallables; - /** * @var string */ @@ -93,15 +121,15 @@ class Instance private $iam; /** - * Create an object representing a Google Cloud Spanner instance. + * Create an object representing a Cloud Spanner instance. * * @param ConnectionInterface $connection The connection to the - * Google Cloud Spanner Admin API. + * Cloud Spanner Admin API. * @param LongRunningConnectionInterface $lroConnection An implementation * mapping to methods which handle LRO resolution in the service. * @param array $lroCallables * @param string $projectId The project ID. - * @param string $name The instance name. + * @param string $name The instance name or ID. * @param bool $returnInt64AsObject [optional] If true, 64 bit integers will be * returned as a {@see Google\Cloud\Core\Int64} object for 32 bit platform * compatibility. **Defaults to** false. @@ -117,12 +145,12 @@ public function __construct( array $info = [] ) { $this->connection = $connection; - $this->lroConnection = $lroConnection; - $this->lroCallables = $lroCallables; $this->projectId = $projectId; - $this->name = $name; + $this->name = $this->fullyQualifiedInstanceName($name, $projectId); $this->returnInt64AsObject = $returnInt64AsObject; $this->info = $info; + + $this->setLroProperties($lroConnection, $lroCallables, $this->name); } /** @@ -207,12 +235,58 @@ public function exists(array $options = []) public function reload(array $options = []) { $this->info = $this->connection->getInstance($options + [ - 'name' => $this->fullyQualifiedInstanceName() + 'name' => $this->name ]); return $this->info; } + /** + * Create a new instance. + * + * Example: + * ``` + * $operation = $instance->create($configuration); + * ``` + * + * @codingStandardsIgnoreStart + * @see https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.instance.v1#createinstancerequest CreateInstanceRequest + * + * @param InstanceConfiguration $config The configuration to use + * @param string $name The instance name + * @param array $options [optional] { + * Configuration options + * + * @type string $displayName **Defaults to** the value of $name. + * @type int $nodeCount **Defaults to** `1`. + * @type array $labels For more information, see + * [Using labels to organize Google Cloud Platform resources](https://cloudplatform.googleblog.com/2015/10/using-labels-to-organize-Google-Cloud-Platform-resources.html). + * } + * @return LongRunningOperation + * @codingStandardsIgnoreEnd + */ + public function create(InstanceConfiguration $config, array $options = []) + { + $instanceId = InstanceAdminClient::parseInstanceFromInstanceName($this->name); + $options += [ + 'displayName' => $instanceId, + 'nodeCount' => self::DEFAULT_NODE_COUNT, + 'labels' => [], + ]; + + // This must always be set to CREATING, so overwrite anything else. + $options['state'] = State::CREATING; + + $operation = $this->connection->createInstance([ + 'instanceId' => $instanceId, + 'name' => $this->name, + 'projectId' => InstanceAdminClient::formatProjectName($this->projectId), + 'config' => $config->name() + ] + $options); + + return $this->resumeOperation($operation['name'], $operation); + } + /** * Return the instance state. * @@ -269,21 +343,11 @@ public function state(array $options = []) */ public function update(array $options = []) { - $info = $this->info($options); - - $options += [ - 'displayName' => $info['displayName'], - 'nodeCount' => (isset($info['nodeCount'])) ? $info['nodeCount'] : null, - 'labels' => (isset($info['labels'])) - ? $info['labels'] - : [] - ]; - $operation = $this->connection->updateInstance([ - 'name' => $this->fullyQualifiedInstanceName(), + 'name' => $this->name, ] + $options); - return $this->lro($this->lroConnection, $operation['name'], $this->lroCallables); + return $this->resumeOperation($operation['name'], $operation); } /** @@ -304,7 +368,7 @@ public function update(array $options = []) public function delete(array $options = []) { return $this->connection->deleteInstance($options + [ - 'name' => $this->fullyQualifiedInstanceName() + 'name' => $this->name ]); } @@ -313,7 +377,7 @@ public function delete(array $options = []) * * Example: * ``` - * $database = $instance->createDatabase('my-database'); + * $operation = $instance->createDatabase('my-database'); * ``` * * @codingStandardsIgnoreStart @@ -325,24 +389,17 @@ public function delete(array $options = []) * Configuration Options * * @type array $statements Additional DDL statements. + * @type SessionPoolInterface $sessionPool A pool used to manage + * sessions. * } * @return LongRunningOperation */ public function createDatabase($name, array $options = []) { - $options += [ - 'statements' => [], - ]; + $instantiation = $this->pluckArray(['sessionPool'], $options); - $statement = sprintf('CREATE DATABASE `%s`', $name); - - $operation = $this->connection->createDatabase([ - 'instance' => $this->fullyQualifiedInstanceName(), - 'createStatement' => $statement, - 'extraStatements' => $options['statements'] - ]); - - return $this->lro($this->lroConnection, $operation['name'], $this->lroCallables); + $database = $this->database($name, $instantiation); + return $database->create($options); } /** @@ -406,11 +463,10 @@ public function databases(array $options = []) return new ItemIterator( new PageIterator( function (array $database) { - $name = DatabaseAdminClient::parseDatabaseFromDatabaseName($database['name']); - return $this->database($name); + return $this->database($database['name']); }, [$this->connection, 'listDatabases'], - $options + ['instance' => $this->fullyQualifiedInstanceName()], + $options + ['instance' => $this->name], [ 'itemsKey' => 'databases', 'resultLimit' => $resultLimit @@ -434,7 +490,7 @@ public function iam() if (!$this->iam) { $this->iam = new Iam( new IamInstance($this->connection), - $this->fullyQualifiedInstanceName() + $this->name ); } @@ -444,11 +500,20 @@ public function iam() /** * Convert the simple instance name to a fully qualified name. * + * @param string $name The instance name. + * @param string $project The project ID. * @return string */ - private function fullyQualifiedInstanceName() + private function fullyQualifiedInstanceName($name, $project) { - return InstanceAdminClient::formatInstanceName($this->projectId, $this->name); + // try { + return InstanceAdminClient::formatInstanceName( + $project, + $name + ); + // } catch (ValidationException $e) { + // return $name; + // } } /** diff --git a/src/Spanner/Configuration.php b/src/Spanner/InstanceConfiguration.php similarity index 82% rename from src/Spanner/Configuration.php rename to src/Spanner/InstanceConfiguration.php index 1fbc1da9a4b1..ffa08fb62363 100644 --- a/src/Spanner/Configuration.php +++ b/src/Spanner/InstanceConfiguration.php @@ -20,9 +20,10 @@ use Google\Cloud\Core\Exception\NotFoundException; use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; use Google\Cloud\Spanner\Connection\ConnectionInterface; +use Google\GAX\ValidationException; /** - * Represents a Cloud Spanner Configuration. + * Represents a Cloud Spanner Instance Configuration. * * Example: * ``` @@ -30,14 +31,14 @@ * * $spanner = new SpannerClient(); * - * $configuration = $spanner->configuration('regional-europe-west'); + * $configuration = $spanner->instanceConfiguration('regional-europe-west'); * ``` * * @codingStandardsIgnoreStart * @see https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.instance.v1#instanceconfig InstanceConfig * @codingStandardsIgnoreEnd */ -class Configuration +class InstanceConfiguration { /** * @var ConnectionInterface @@ -60,12 +61,12 @@ class Configuration private $info; /** - * Create a configuration instance. + * Create an instance configuration object. * * @param ConnectionInterface $connection A service connection for the * Spanner API. * @param string $projectId The current project ID. - * @param string $name The simple configuration name. + * @param string $name The configuration name or ID. * @param array $info [optional] A service representation of the * configuration. */ @@ -77,7 +78,7 @@ public function __construct( ) { $this->connection = $connection; $this->projectId = $projectId; - $this->name = $name; + $this->name = $this->fullyQualifiedConfigName($name, $projectId); $this->info = $info; } @@ -167,8 +168,8 @@ public function exists(array $options = []) */ public function reload(array $options = []) { - $this->info = $this->connection->getConfig($options + [ - 'name' => InstanceAdminClient::formatInstanceConfigName($this->projectId, $this->name), + $this->info = $this->connection->getInstanceConfig($options + [ + 'name' => $this->name, 'projectId' => $this->projectId ]); @@ -190,4 +191,23 @@ public function __debugInfo() 'info' => $this->info, ]; } + + /** + * Get the fully qualified instance config name. + * + * @param string $name The configuration name. + * @param string $projectId The project ID. + * @return string + */ + private function fullyQualifiedConfigName($name, $projectId) + { + try { + return InstanceAdminClient::formatInstanceConfigName( + $projectId, + $name + ); + } catch (ValidationException $e) { + return $name; + } + } } diff --git a/src/Spanner/KeyRange.php b/src/Spanner/KeyRange.php index db5a432ba029..3e6b00324467 100644 --- a/src/Spanner/KeyRange.php +++ b/src/Spanner/KeyRange.php @@ -18,7 +18,7 @@ namespace Google\Cloud\Spanner; /** - * Represents a Google Cloud Spanner KeyRange. + * Represents a Cloud Spanner KeyRange. * * Example: * ``` @@ -110,6 +110,30 @@ public function __construct(array $options = []) $this->end = $options['end']; } + /** + * Returns a key range that covers all keys where the first components match. + * + * Equivalent to calling `KeyRange::__construct()` with closed type for start + * and end, and the same key for the start and end. + * + * Example: + * ``` + * $range = KeyRange::prefixMatch($key); + * ``` + * + * @param array $key The key to match against. + * @return KeyRange + */ + public static function prefixMatch(array $key) + { + return new static([ + 'startType' => self::TYPE_CLOSED, + 'endType' => self::TYPE_CLOSED, + 'start' => $key, + 'end' => $key + ]); + } + /** * Get the range start. * diff --git a/src/Spanner/KeySet.php b/src/Spanner/KeySet.php index 1ee4259f9bac..ea3893a814da 100644 --- a/src/Spanner/KeySet.php +++ b/src/Spanner/KeySet.php @@ -20,7 +20,7 @@ use Google\Cloud\Core\ValidateTrait; /** - * Represents a Google Cloud Spanner KeySet. + * Represents a Cloud Spanner KeySet. * * Example: * ``` @@ -74,6 +74,14 @@ public function __construct(array $options = []) $this->validateBatch($options['ranges'], KeyRange::class); + if (!is_array($options['keys'])) { + throw new \InvalidArgumentException('$options.keys must be an array.'); + } + + if (!is_bool($options['all'])) { + throw new \InvalidArgumentException('$options.all must be a boolean.'); + } + $this->keys = $options['keys']; $this->ranges = $options['ranges']; $this->all = (bool) $options['all']; @@ -230,10 +238,20 @@ public function keySetObject() $ranges[] = $range->keyRangeObject(); } - return [ - 'keys' => $this->keys, - 'ranges' => $ranges, - 'all' => $this->all - ]; + $set = []; + + if ($this->all) { + $set['all'] = true; + } + + if ($this->keys) { + $set['keys'] = $this->keys; + } + + if ($ranges) { + $set['ranges'] = $ranges; + } + + return $set; } } diff --git a/src/Spanner/Operation.php b/src/Spanner/Operation.php index e9bddbf9a88e..bc4fde898770 100644 --- a/src/Spanner/Operation.php +++ b/src/Spanner/Operation.php @@ -24,9 +24,9 @@ use Google\Cloud\Spanner\Session\SessionPoolInterface; /** - * Common interface for running operations against Google Cloud Spanner. This - * class is intended for internal use by the client library only. Implementors - * should access these operations via {@see Google\Cloud\Spanner\Database} or + * Common interface for running operations against Cloud Spanner. This class is + * intended for internal use by the client library only. Implementors should + * access these operations via {@see Google\Cloud\Spanner\Database} or * {@see Google\Cloud\Spanner\Transaction}. * * Usage examples may be found in classes making use of this class: @@ -163,11 +163,12 @@ public function execute(Session $session, $sql, array $options = []) { $options += [ 'parameters' => [], + 'types' => [], 'transactionContext' => null ]; $parameters = $this->pluck('parameters', $options); - $options += $this->mapper->formatParamsForExecuteSql($parameters); + $options += $this->mapper->formatParamsForExecuteSql($parameters, $options['types']); $context = $this->pluck('transactionContext', $options); @@ -234,12 +235,28 @@ public function read(Session $session, $table, KeySet $keySet, array $columns, a * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest * * @param Session $session The session to start the transaction in. - * @param array $options [optional] Configuration options. + * @param array $options [optional] { + * Configuration Options. + * + * @type bool $singleUse If true, a Transaction ID will not be allocated + * up front. Instead, the transaction will be considered + * "single-use", and may be used for only a single operation. + * **Defaults to** `false`. + * } * @return Transaction */ public function transaction(Session $session, array $options = []) { - $res = $this->beginTransaction($session, $options); + $options += [ + 'singleUse' => false + ]; + + if (!$options['singleUse']) { + $res = $this->beginTransaction($session, $options); + } else { + $res = []; + } + return $this->createTransaction($session, $res); } @@ -249,25 +266,44 @@ public function transaction(Session $session, array $options = []) * @see https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.BeginTransactionRequest BeginTransactionRequest * * @param Session $session The session to start the snapshot in. - * @param array $options [optional] Configuration options. + * @param array $options [optional] { + * Configuration Options. + * + * @type bool $singleUse If true, a Transaction ID will not be allocated + * up front. Instead, the transaction will be considered + * "single-use", and may be used for only a single operation. + * **Defaults to** `false`. + * } * @return Snapshot */ public function snapshot(Session $session, array $options = []) { - $res = $this->beginTransaction($session, $options); + $options += [ + 'singleUse' => false + ]; - return $this->createSnapshot($session, $res); + if (!$options['singleUse']) { + $res = $this->beginTransaction($session, $options); + } else { + $res = []; + } + + return $this->createSnapshot($session, $res + $options); } /** * Create a Transaction instance from a response object. * * @param Session $session The session the transaction belongs to. - * @param array $res The transaction response. + * @param array $res [optional] The createTransaction response. * @return Transaction */ - public function createTransaction(Session $session, array $res) + public function createTransaction(Session $session, array $res = []) { + $res += [ + 'id' => null + ]; + return new Transaction($this, $session, $res['id']); } @@ -275,17 +311,21 @@ public function createTransaction(Session $session, array $res) * Create a Snapshot instance from a response object. * * @param Session $session The session the snapshot belongs to. - * @param array $res The snapshot response. + * @param array $res [optional] The createTransaction response. * @return Snapshot */ - public function createSnapshot(Session $session, array $res) + public function createSnapshot(Session $session, array $res = []) { - $timestamp = null; - if (isset($res['readTimestamp'])) { - $timestamp = $this->mapper->createTimestampWithNanos($res['readTimestamp']); + $res += [ + 'id' => null, + 'readTimestamp' => null + ]; + + if ($res['readTimestamp']) { + $res['readTimestamp'] = $this->mapper->createTimestampWithNanos($res['readTimestamp']); } - return new Snapshot($this, $session, $res['id'], $timestamp); + return new Snapshot($this, $session, $res); } /** diff --git a/src/Spanner/Result.php b/src/Spanner/Result.php index 40b1d09309f7..727f2e6fe3b8 100644 --- a/src/Spanner/Result.php +++ b/src/Spanner/Result.php @@ -20,9 +20,10 @@ use Google\Cloud\Core\Exception\ServiceException; use Google\Cloud\Spanner\Session\Session; use Google\Cloud\Spanner\Session\SessionPoolInterface; +use Google\Cloud\Spanner\Timestamp; /** - * Represent a Google Cloud Spanner lookup result (either read or executeSql). + * Represent a Cloud Spanner lookup result (either read or executeSql). * * Example: * ``` diff --git a/src/Spanner/Session/CacheSessionPool.php b/src/Spanner/Session/CacheSessionPool.php index da881bc7860b..2d8702d2cb1e 100644 --- a/src/Spanner/Session/CacheSessionPool.php +++ b/src/Spanner/Session/CacheSessionPool.php @@ -49,7 +49,7 @@ * use Symfony\Component\Cache\Adapter\FilesystemAdapter; * * $spanner = new SpannerClient(); - * $cache = new FilesystemAdapter() + * $cache = new FilesystemAdapter(); * $sessionPool = new CacheSessionPool($cache); * * $database = $spanner->connect('my-instance', 'my-database', [ @@ -244,7 +244,8 @@ public function release(Session $session) unset($data['inUse'][$session->name()]); array_push($data['queue'], [ 'name' => $session->name(), - 'expiration' => $session->expiration() ?: $this->time() + SessionPoolInterface::SESSION_EXPIRATION_SECONDS + 'expiration' => $session->expiration() + ?: $this->time() + SessionPoolInterface::SESSION_EXPIRATION_SECONDS ]); $this->cacheItemPool->save($item->set($data)); }); diff --git a/src/Spanner/Snapshot.php b/src/Spanner/Snapshot.php index 8655e7d5349e..6a7ba5bd8ab8 100644 --- a/src/Spanner/Snapshot.php +++ b/src/Spanner/Snapshot.php @@ -105,9 +105,9 @@ * @return string * } */ -class Snapshot +class Snapshot implements TransactionalReadInterface { - use TransactionReadTrait; + use TransactionalReadTrait; /** * @var Timestamp @@ -117,20 +117,39 @@ class Snapshot /** * @param Operation $operation The Operation instance. * @param Session $session The session to use for spanner interactions. - * @param string $transactionId The Transaction ID. - * @param Timestamp $readTimestamp [optional] The read timestamp. + * @param array $options [optional] { + * Configuration Options. + * + * @type string $id The Transaction ID. If no ID is provided, + * the Transaction will be a Single-Use Transaction. + * @type Timestamp $readTimestamp The read timestamp. + * } */ public function __construct( Operation $operation, Session $session, - $transactionId, - Timestamp $readTimestamp = null + array $options = [] ) { $this->operation = $operation; $this->session = $session; - $this->transactionId = $transactionId; - $this->readTimestamp = $readTimestamp; - $this->context = SessionPoolInterface::CONTEXT_READWRITE; + + $options += [ + 'id' => null, + 'readTimestamp' => null + ]; + + if ($options['readTimestamp'] && !($options['readTimestamp'] instanceof Timestamp)) { + throw new \InvalidArgumentException('$options.readTimestamp must be an instance of Timestamp.'); + } + + $this->transactionId = $options['id'] ?: null; + $this->readTimestamp = $options['readTimestamp']; + $this->type = $options['id'] + ? self::TYPE_PRE_ALLOCATED + : self::TYPE_SINGLE_USE; + + $this->context = SessionPoolInterface::CONTEXT_READ; + $this->options = $options; } /** diff --git a/src/Spanner/SpannerClient.php b/src/Spanner/SpannerClient.php index c28c4ef0db68..5b72de2a684a 100644 --- a/src/Spanner/SpannerClient.php +++ b/src/Spanner/SpannerClient.php @@ -34,9 +34,9 @@ use Psr\Http\StreamInterface; /** - * Google Cloud Spanner is a highly scalable, transactional, managed, NewSQL + * Cloud Spanner is a highly scalable, transactional, managed, NewSQL * database service. Find more information at - * [Google Cloud Spanner docs](https://cloud.google.com/spanner/). + * [Cloud Spanner docs](https://cloud.google.com/spanner/). * * Example: * ``` @@ -44,6 +44,19 @@ * * $spanner = new SpannerClient(); * ``` + * + * @method resumeOperation() { + * Resume a Long Running Operation + * + * Example: + * ``` + * $operation = $spanner->resumeOperation($operationName); + * ``` + * + * @param string $operationName The Long Running Operation name. + * @param array $info [optional] The operation data. + * @return LongRunningOperation + * } */ class SpannerClient { @@ -56,18 +69,12 @@ class SpannerClient const FULL_CONTROL_SCOPE = 'https://www.googleapis.com/auth/spanner.data'; const ADMIN_SCOPE = 'https://www.googleapis.com/auth/spanner.admin'; - const DEFAULT_NODE_COUNT = 1; /** * @var ConnectionInterface */ protected $connection; - /** - * @var LongRunningConnectionInterface - */ - private $lroConnection; - /** * @var bool */ @@ -110,9 +117,9 @@ public function __construct(array $config = []) ]; $this->connection = new Grpc($this->configureAuthentication($config)); - $this->lroConnection = new LongRunningConnection($this->connection); $this->returnInt64AsObject = $config['returnInt64AsObject']; - $this->lroCallables = [ + + $this->setLroProperties(new LongRunningConnection($this->connection), [ [ 'typeUrl' => 'type.googleapis.com/google.spanner.admin.instance.v1.UpdateInstanceMetadata', 'callable' => function ($instance) { @@ -135,15 +142,15 @@ public function __construct(array $config = []) return $this->instance($name, $instance); } ] - ]; + ]); } /** - * List all available configurations. + * List all available instance configurations. * * Example: * ``` - * $configurations = $spanner->configurations(); + * $configurations = $spanner->instanceConfigurations(); * ``` * * @codingStandardsIgnoreStart @@ -160,19 +167,18 @@ public function __construct(array $config = []) * @type string $pageToken A previously-returned page token used to * resume the loading of results from a specific point. * } - * @return ItemIterator + * @return ItemIterator */ - public function configurations(array $options = []) + public function instanceConfigurations(array $options = []) { $resultLimit = $this->pluck('resultLimit', $options, false) ?: 0; return new ItemIterator( new PageIterator( function (array $config) { - $name = InstanceAdminClient::parseInstanceConfigFromInstanceConfigName($config['name']); - return $this->configuration($name, $config); + return $this->instanceConfiguration($config['name'], $config); }, - [$this->connection, 'listConfigs'], + [$this->connection, 'listInstanceConfigs'], ['projectId' => InstanceAdminClient::formatProjectName($this->projectId)] + $options, [ 'itemsKey' => 'instanceConfigs', @@ -183,17 +189,17 @@ function (array $config) { } /** - * Get a configuration by its name. + * Get an instance configuration by its name. * * NOTE: This method does not execute a service request and does not verify * the existence of the given configuration. Unless you know with certainty * that the configuration exists, it is advised that you use - * {@see Google\Cloud\Spanner\Configuration::exists()} to verify existence - * before attempting to use the configuration. + * {@see Google\Cloud\Spanner\InstanceConfiguration::exists()} to verify + * existence before attempting to use the configuration. * * Example: * ``` - * $configuration = $spanner->configuration($configurationName); + * $configuration = $spanner->instanceConfiguration($configurationName); * ``` * * @codingStandardsIgnoreStart @@ -202,11 +208,11 @@ function (array $config) { * * @param string $name The Configuration name. * @param array $config [optional] The configuration details. - * @return Configuration + * @return InstanceConfiguration */ - public function configuration($name, array $config = []) + public function instanceConfiguration($name, array $config = []) { - return new Configuration($this->connection, $this->projectId, $name, $config); + return new InstanceConfiguration($this->connection, $this->projectId, $name, $config); } /** @@ -220,7 +226,7 @@ public function configuration($name, array $config = []) * @codingStandardsIgnoreStart * @see https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.admin.instance.v1#createinstancerequest CreateInstanceRequest * - * @param Configuration $config The configuration to use + * @param InstanceConfiguration $config The configuration to use * @param string $name The instance name * @param array $options [optional] { * Configuration options @@ -233,26 +239,10 @@ public function configuration($name, array $config = []) * @return LongRunningOperation * @codingStandardsIgnoreEnd */ - public function createInstance(Configuration $config, $name, array $options = []) + public function createInstance(InstanceConfiguration $config, $name, array $options = []) { - $options += [ - 'displayName' => $name, - 'nodeCount' => self::DEFAULT_NODE_COUNT, - 'labels' => [], - 'operationName' => null, - ]; - - // This must always be set to CREATING, so overwrite anything else. - $options['state'] = State::CREATING; - - $operation = $this->connection->createInstance([ - 'instanceId' => $name, - 'name' => InstanceAdminClient::formatInstanceName($this->projectId, $name), - 'projectId' => InstanceAdminClient::formatProjectName($this->projectId), - 'config' => InstanceAdminClient::formatInstanceConfigName($this->projectId, $config->name()) - ] + $options); - - return $this->lro($this->lroConnection, $operation['name'], $this->lroCallables); + $instance = $this->instance($name); + return $instance->create($config, $options); } /** @@ -504,20 +494,4 @@ public function duration($seconds, $nanos = 0) { return new Duration($seconds, $nanos); } - - /** - * Resume a Long Running Operation - * - * Example: - * ``` - * $operation = $spanner->resumeOperation($operationName); - * ``` - * - * @param string $operationName The Long Running Operation name. - * @return LongRunningOperation - */ - public function resumeOperation($operationName) - { - return $this->lro($this->lroConnection, $operationName, $this->lroCallables); - } } diff --git a/src/Spanner/Transaction.php b/src/Spanner/Transaction.php index e9c124324e38..86c4ae32e448 100644 --- a/src/Spanner/Transaction.php +++ b/src/Spanner/Transaction.php @@ -17,12 +17,13 @@ namespace Google\Cloud\Spanner; +use Google\Cloud\Core\Exception\AbortedException; use Google\Cloud\Spanner\Session\Session; use Google\Cloud\Spanner\Session\SessionPoolInterface; use RuntimeException; /** - * Manages interaction with Google Cloud Spanner inside a Transaction. + * Manages interaction with Cloud Spanner inside a Transaction. * * Transactions can be started via * {@see Google\Cloud\Spanner\Database::runTransaction()} (recommended) or via @@ -65,6 +66,8 @@ * @method execute() { * Run a query. * + * Note that this method is not available in a single-use transaction. + * * Example: * ``` * $result = $transaction->execute( @@ -94,6 +97,8 @@ * @method read() { * Lookup rows in a table. * + * Note that this method is not available in a single-use transaction. + * * Example: * ``` * $keySet = new KeySet([ @@ -132,37 +137,34 @@ * @return string * } */ -class Transaction +class Transaction implements TransactionalReadInterface { - use TransactionReadTrait; - - const STATE_ACTIVE = 0; - const STATE_ROLLED_BACK = 1; - const STATE_COMMITTED = 2; + use TransactionalReadTrait; /** * @var array */ private $mutations = []; - /** - * @var int - */ - private $state = self::STATE_ACTIVE; - /** * @param Operation $operation The Operation instance. * @param Session $session The session to use for spanner interactions. - * @param string $transactionId The Transaction ID. + * @param string $transactionId [optional] The Transaction ID. If no ID is + * provided, the Transaction will be a Single-Use Transaction. */ public function __construct( Operation $operation, Session $session, - $transactionId + $transactionId = null ) { $this->operation = $operation; $this->session = $session; $this->transactionId = $transactionId; + + $this->type = $transactionId + ? self::TYPE_PRE_ALLOCATED + : self::TYPE_SINGLE_USE; + $this->context = SessionPoolInterface::CONTEXT_READWRITE; } @@ -395,7 +397,11 @@ public function delete($table, KeySet $keySet) public function rollback(array $options = []) { if ($this->state !== self::STATE_ACTIVE) { - throw new \RuntimeException('The transaction cannot be rolled back because it is not active'); + throw new \BadMethodCallException('The transaction cannot be rolled back because it is not active'); + } + + if ($this->type === self::TYPE_SINGLE_USE) { + throw new \BadMethodCallException('Cannot roll back a single-use transaction.'); } $this->state = self::STATE_ROLLED_BACK; @@ -416,21 +422,39 @@ public function rollback(array $options = []) * $transaction->commit(); * ``` * - * @param array $options [optional] Configuration Options. + * @param array $options [optional] { + * Configuration Options. + * + * @type array $mutations An array of mutations to commit. May be used + * instead of or in addition to enqueing mutations separately. + * } * @return Timestamp The commit timestamp. - * @throws \RuntimeException If the transaction is not active - * @throws \AbortedException If the commit is aborted for any reason. + * @throws \BadMethodCall If the transaction is not active or already used. + * @throws AbortedException If the commit is aborted for any reason. */ public function commit(array $options = []) { if ($this->state !== self::STATE_ACTIVE) { - throw new \RuntimeException('The transaction cannot be committed because it is not active'); + throw new \BadMethodCallException('The transaction cannot be committed because it is not active'); + } + + if (!$this->singleUseState()) { + $this->state = self::STATE_COMMITTED; } - $this->state = self::STATE_COMMITTED; + $options += [ + 'mutations' => [] + ]; + + $options['mutations'] += $this->mutations; $options['transactionId'] = $this->transactionId; - return $this->operation->commit($this->session, $this->mutations, $options); + + $t = $this->transactionOptions($options); + + $options[$t[1]] = $t[0]; + + return $this->operation->commit($this->session, $this->pluck('mutations', $options), $options); } /** @@ -451,11 +475,6 @@ public function state() return $this->state; } - public function id() - { - return $this->transactionId; - } - /** * Format, validate and enqueue mutations in the transaction. * diff --git a/src/Spanner/TransactionConfigurationTrait.php b/src/Spanner/TransactionConfigurationTrait.php index a5f5e61b4c61..ac7d9582f3b5 100644 --- a/src/Spanner/TransactionConfigurationTrait.php +++ b/src/Spanner/TransactionConfigurationTrait.php @@ -21,7 +21,7 @@ use Google\Cloud\Spanner\Session\SessionPoolInterface; /** - * Manage shared transaction configuration. + * Configure transaction selection for read, executeSql, rollback and commit. */ trait TransactionConfigurationTrait { @@ -34,25 +34,60 @@ trait TransactionConfigurationTrait * or read/write. * * @param array $options call options. - * @return array + * @param array $previous Previously given call options (for single-use snapshots). + * @return array [(array) transaction selector, (string) context] */ - private function transactionSelector(array &$options) + private function transactionSelector(array &$options, array $previous = []) { $options += [ 'begin' => false, 'transactionType' => SessionPoolInterface::CONTEXT_READ, - 'transactionId' => null + ]; + + $res = $this->transactionOptions($options, $previous); + + // TransactionSelector uses a different key name for singleUseTransaction + // and transactionId than transactionOptions, so we'll rewrite those here + // so transactionOptions works as expected for commitRequest. + + $type = $res[1]; + if ($type === 'singleUseTransaction') { + $type = 'singleUse'; + } elseif ($type === 'transactionId') { + $type = 'id'; + } + + return [ + [$type => $res[0]], + $res[2] + ]; + } + + /** + * Return transaction options based on given configuration options. + * + * @param array $options call options. + * @param array $previous Previously given call options (for single-use snapshots). + * @return array [(array) transaction options, (string) transaction type, (string) context] + */ + private function transactionOptions(array &$options, array $previous = []) + { + $options += [ + 'begin' => false, + 'transactionType' => SessionPoolInterface::CONTEXT_READWRITE, + 'transactionId' => null, ]; $type = null; $context = $this->pluck('transactionType', $options); $id = $this->pluck('transactionId', $options); + if (!is_null($id)) { - $type = 'id'; + $type = 'transactionId'; $transactionOptions = $id; } elseif ($context === SessionPoolInterface::CONTEXT_READ) { - $transactionOptions = $this->configureSnapshotOptions($options); + $transactionOptions = $this->configureSnapshotOptions($options, $previous); } elseif ($context === SessionPoolInterface::CONTEXT_READWRITE) { $transactionOptions = $this->configureTransactionOptions(); } else { @@ -64,13 +99,10 @@ private function transactionSelector(array &$options) $begin = $this->pluck('begin', $options); if (is_null($type)) { - $type = ($begin) ? 'begin' : 'singleUse'; + $type = ($begin) ? 'begin' : 'singleUseTransaction'; } - return [ - [$type => $transactionOptions], - $context - ]; + return [$transactionOptions, $type, $context]; } private function configureTransactionOptions() @@ -81,14 +113,16 @@ private function configureTransactionOptions() } /** - * Create a Read Only single use transaction. + * Configure a Read-Only transaction. * * @param array $options Configuration Options. + * @param array $previous Previously given call options (for single-use snapshots). * @return array */ - private function configureSnapshotOptions(array &$options) + private function configureSnapshotOptions(array &$options, array $previous = []) { $options += [ + 'singleUse' => false, 'returnReadTimestamp' => null, 'strong' => null, 'readTimestamp' => null, @@ -97,6 +131,17 @@ private function configureSnapshotOptions(array &$options) 'maxStaleness' => null, ]; + $previousOptions = isset($previous['transactionOptions']['readOnly']) + ? $previous['transactionOptions']['readOnly'] + : []; + + // These are only available in single-use transactions. + if (!$options['singleUse'] && ($options['maxStaleness'] || $options['minReadTimestamp'])) { + throw new \BadMethodCallException( + 'maxStaleness and minReadTimestamp are only available in single-use transactions.' + ); + } + $transactionOptions = [ 'readOnly' => $this->arrayFilterRemoveNull([ 'returnReadTimestamp' => $this->pluck('returnReadTimestamp', $options), @@ -105,7 +150,7 @@ private function configureSnapshotOptions(array &$options) 'maxStaleness' => $this->pluck('maxStaleness', $options), 'readTimestamp' => $this->pluck('readTimestamp', $options), 'exactStaleness' => $this->pluck('exactStaleness', $options), - ]) + ]) + $previousOptions ]; if (empty($transactionOptions['readOnly'])) { @@ -123,7 +168,7 @@ private function configureSnapshotOptions(array &$options) ]; foreach ($timestampFields as $tsf) { - if (isset($transactionOptions['readOnly'][$tsf])) { + if (isset($transactionOptions['readOnly'][$tsf]) && !isset($previousOptions[$tsf])) { $field = $transactionOptions['readOnly'][$tsf]; if (!($field instanceof Timestamp)) { throw new \BadMethodCallException(sprintf( @@ -137,7 +182,7 @@ private function configureSnapshotOptions(array &$options) } foreach ($durationFields as $df) { - if (isset($transactionOptions['readOnly'][$df])) { + if (isset($transactionOptions['readOnly'][$df]) && !isset($previousOptions[$df])) { $field = $transactionOptions['readOnly'][$df]; if (!($field instanceof Duration)) { throw new \BadMethodCallException(sprintf( diff --git a/src/Spanner/TransactionalReadInterface.php b/src/Spanner/TransactionalReadInterface.php new file mode 100644 index 000000000000..0b6ae3d11e76 --- /dev/null +++ b/src/Spanner/TransactionalReadInterface.php @@ -0,0 +1,67 @@ +context; + $this->singleUseState(); + $this->checkReadContext(); + $options['transactionId'] = $this->transactionId; + $options['transactionType'] = $this->context; - list($transactionOptions, $context) = $this->transactionSelector($options); - $options['transaction'] = $transactionOptions; - $options['transactionContext'] = $context; + $selector = $this->transactionSelector($options, $this->options); + + $options['transaction'] = $selector[0]; return $this->operation->execute($this->session, $sql, $options); } @@ -79,19 +99,21 @@ public function execute($sql, array $options = []) * Configuration Options. * * @type string $index The name of an index on the table. - * @type int $offset The number of rows to offset results by. * @type int $limit The number of results to return. * } * @return Result */ public function read($table, KeySet $keySet, array $columns, array $options = []) { - $options['transactionType'] = $this->context; + $this->singleUseState(); + $this->checkReadContext(); + $options['transactionId'] = $this->transactionId; + $options['transactionType'] = $this->context; + $options += $this->options; + $selector = $this->transactionSelector($options, $this->options); - list($transactionOptions, $context) = $this->transactionSelector($options); - $options['transaction'] = $transactionOptions; - $options['transactionContext'] = $context; + $options['transaction'] = $selector[0]; return $this->operation->read($this->session, $table, $keySet, $columns, $options); } @@ -99,10 +121,54 @@ public function read($table, KeySet $keySet, array $columns, array $options = [] /** * Retrieve the Transaction ID. * - * @return string + * @return string|null */ public function id() { return $this->transactionId; } + + /** + * Get the Transaction Type. + * + * @return int + */ + public function type() + { + return $this->type; + } + + /** + * Check the transaction state, and update as necessary for single-use transactions. + * + * @return bool true if transaction is single use, false otherwise. + * @throws \BadMethodCallException + */ + private function singleUseState() + { + if ($this->type === self::TYPE_SINGLE_USE) { + if ($this->state === self::STATE_SINGLE_USE_USED) { + throw new \BadMethodCallException('This single-use transaction has already been used.'); + } + + $this->state = self::STATE_SINGLE_USE_USED; + + return true; + } + + return false; + } + + /** + * Check whether the context is valid for a read operation. Reads are not + * allowed in single-use read-write transactions. + * + * @throws \BadMethodCallException + */ + private function checkReadContext() + { + if ($this->type === self::TYPE_SINGLE_USE && $this->context === SessionPoolInterface::CONTEXT_READWRITE) { + throw new \BadMethodCallException('Cannot use a single-use read-write transaction for read or execute.'); + } + } } diff --git a/src/Spanner/V1/SpannerClient.php b/src/Spanner/V1/SpannerClient.php index a88419ad1441..8bd3507e90d4 100644 --- a/src/Spanner/V1/SpannerClient.php +++ b/src/Spanner/V1/SpannerClient.php @@ -28,6 +28,7 @@ namespace Google\Cloud\Spanner\V1; +use DrSlump\Protobuf\Codec\PhpArray; use Google\GAX\AgentHeaderDescriptor; use Google\GAX\ApiCallable; use Google\GAX\CallSettings; diff --git a/src/Spanner/ValueMapper.php b/src/Spanner/ValueMapper.php index 39c6620e07d0..8118fe72df65 100644 --- a/src/Spanner/ValueMapper.php +++ b/src/Spanner/ValueMapper.php @@ -39,7 +39,21 @@ class ValueMapper const TYPE_BYTES = TypeCode::TYPE_BYTES; const TYPE_ARRAY = TypeCode::TYPE_ARRAY; const TYPE_STRUCT = TypeCode::TYPE_STRUCT; - const TYPE_NULL = 'null'; + + /** + * @var array + */ + private $allowedTypes = [ + self::TYPE_BOOL, + self::TYPE_INT64, + self::TYPE_FLOAT64, + self::TYPE_TIMESTAMP, + self::TYPE_DATE, + self::TYPE_STRING, + self::TYPE_BYTES, + self::TYPE_ARRAY, + self::TYPE_STRUCT, + ]; /** * @var bool @@ -60,14 +74,32 @@ public function __construct($returnInt64AsObject) * an array of parameters and inferred parameter types. * * @param array $parameters The key/value parameters. + * @param array $types The types of values. * @return array An associative array containing params and paramTypes. */ - public function formatParamsForExecuteSql(array $parameters) + public function formatParamsForExecuteSql(array $parameters, array $types = []) { $paramTypes = []; foreach ($parameters as $key => $value) { - list ($parameters[$key], $paramTypes[$key]) = $this->paramType($value); + if (is_null($value) && !isset($types[$key])) { + throw new \BadMethodCallException(sprintf( + 'Null value for parameter @%s must supply a parameter type.', + $key + )); + } + + $type = isset($types[$key]) ? $types[$key] : null; + + if ($type !== null && !in_array($type, $this->allowedTypes)) { + throw new \BadMethodCallException(sprintf( + 'Type %s given for parameter @%s is not valid.', + $type, + $key + )); + } + + list ($parameters[$key], $paramTypes[$key]) = $this->paramType($value, $type); } return [ @@ -135,6 +167,7 @@ public function createTimestampWithNanos($timestamp) $timestamp = preg_replace(self::NANO_REGEX, '.000000Z', $timestamp); $dt = \DateTimeImmutable::createFromFormat(Timestamp::FORMAT, $timestamp); + return new Timestamp($dt, (isset($matches[1])) ? $matches[1] : 0); } @@ -223,7 +256,7 @@ private function decodeValue($value, array $type) * @param mixed $value The PHP value * @return array The Value type */ - private function paramType($value) + private function paramType($value, $givenType = null) { $phpType = gettype($value); switch ($phpType) { @@ -255,7 +288,7 @@ private function paramType($value) case 'array': if ($this->isAssoc($value)) { - throw new \InvalidArgumentException( + throw new \BadMethodCallException( 'Associative arrays are not supported. Did you mean to call a batch method?' ); } @@ -265,11 +298,13 @@ private function paramType($value) foreach ($value as $element) { $type = $this->paramType($element); $res[] = $type[0]; - $types[] = $type[1]['code']; + if (isset($type[1]['code'])) { + $types[] = $type[1]['code']; + } } if (count(array_unique($types)) !== 1) { - throw new \InvalidArgumentException('Array values may not be of mixed type'); + throw new \BadMethodCallException('Array values may not be of mixed type'); } $type = $this->typeObject( @@ -282,7 +317,7 @@ private function paramType($value) break; case 'NULL': - $type = null; + $type = $this->typeObject($givenType); break; default: diff --git a/tests/snippets/Language/LanguageClientTest.php b/tests/snippets/Language/LanguageClientTest.php index f2462709c9b2..d9dd2f9f89ef 100644 --- a/tests/snippets/Language/LanguageClientTest.php +++ b/tests/snippets/Language/LanguageClientTest.php @@ -33,7 +33,7 @@ class LanguageClientTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->client = \Google\Cloud\Dev\stub(NaturalLanguageClient::class); + $this->client = \Google\Cloud\Dev\stub(LanguageClient::class); $this->client->___setProperty('connection', $this->connection->reveal()); } diff --git a/tests/snippets/PubSub/PubSubClientTest.php b/tests/snippets/PubSub/PubSubClientTest.php index 4f1e2ab4b904..5c2fcff18d45 100644 --- a/tests/snippets/PubSub/PubSubClientTest.php +++ b/tests/snippets/PubSub/PubSubClientTest.php @@ -196,7 +196,7 @@ public function testCreateSnapshot() ->shouldBecalled() ->willReturn([]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $snippet = $this->snippetFromMethod(PubSubClient::class, 'createSnapshot'); $snippet->addLocal('pubsub', $this->client); @@ -228,7 +228,7 @@ public function testSnapshots() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $res = $snippet->invoke('snapshots'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); diff --git a/tests/snippets/PubSub/SnapshotTest.php b/tests/snippets/PubSub/SnapshotTest.php index 27469cfa9e8d..9f4c144d7da2 100644 --- a/tests/snippets/PubSub/SnapshotTest.php +++ b/tests/snippets/PubSub/SnapshotTest.php @@ -38,7 +38,7 @@ class SnapshotTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->snapshot = new \SnapshotStub( + $this->snapshot = \Google\Cloud\Dev\stub(Snapshot::class, [ $this->connection->reveal(), self::PROJECT, self::SNAPSHOT, @@ -47,7 +47,7 @@ public function setUp() 'topic' => 'foo', 'subscription' => 'bar' ] - ); + ]); } public function testClass() @@ -89,7 +89,7 @@ public function testCreate() ->shouldBeCalled() ->willReturn($info); - $this->snapshot->setConnection($this->connection->reveal()); + $this->snapshot->___setProperty('connection', $this->connection->reveal()); $snippet = $this->snippetFromMethod(Snapshot::class, 'create'); $snippet->addLocal('snapshot', $this->snapshot); @@ -103,7 +103,7 @@ public function testDelete() $this->connection->deleteSnapshot(Argument::any()) ->shouldBeCalled(); - $this->snapshot->setConnection($this->connection->reveal()); + $this->snapshot->___setProperty('connection', $this->connection->reveal()); $snippet = $this->snippetFromMethod(Snapshot::class, 'delete'); $snippet->addLocal('snapshot', $this->snapshot); diff --git a/tests/snippets/PubSub/SubscriptionTest.php b/tests/snippets/PubSub/SubscriptionTest.php index ddfce689118e..df9c6e9f94de 100644 --- a/tests/snippets/PubSub/SubscriptionTest.php +++ b/tests/snippets/PubSub/SubscriptionTest.php @@ -103,7 +103,7 @@ public function testUpdate() $this->connection->updateSubscription(Argument::any()) ->shouldBeCalled(); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -299,7 +299,7 @@ public function testSeekToTime() $this->connection->seek(Argument::any()) ->shouldBeCalled(); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -312,7 +312,7 @@ public function testSeekToSnapshot() $this->connection->seek(Argument::any()) ->shouldBeCalled(); - $this->subscription->setConnection($this->connection->reveal()); + $this->subscription->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } diff --git a/tests/snippets/ServiceBuilderTest.php b/tests/snippets/ServiceBuilderTest.php index 85e352bf0e18..ab9bc1e6c886 100644 --- a/tests/snippets/ServiceBuilderTest.php +++ b/tests/snippets/ServiceBuilderTest.php @@ -57,7 +57,7 @@ public function serviceBuilderMethods() ['logging', LoggingClient::class, 'logging'], ['language', LanguageClient::class, 'language'], ['pubsub', PubSubClient::class, 'pubsub'], - ['spanner', SpannerClient::class, 'spanner'], + ['spanner', SpannerClient::class, 'spanner', true], ['speech', SpeechClient::class, 'speech'], ['storage', StorageClient::class, 'storage'], ['trace', TraceClient::class, 'trace'], @@ -69,8 +69,14 @@ public function serviceBuilderMethods() /** * @dataProvider serviceBuilderMethods */ - public function testServices($method, $returnType, $returnName) + public function testServices($method, $returnType, $returnName, $skipIfMissingGrpc = false) { + if ($skipIfMissingGrpc) { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + } + $snippet = $this->snippetFromMethod(ServiceBuilder::class, $method); $snippet->addLocal('cloud', $this->cloud); $res = $snippet->invoke($returnName); diff --git a/tests/snippets/Spanner/BytesTest.php b/tests/snippets/Spanner/BytesTest.php index 5b1a629247a6..00209c6f3e8c 100644 --- a/tests/snippets/Spanner/BytesTest.php +++ b/tests/snippets/Spanner/BytesTest.php @@ -38,6 +38,10 @@ public function setUp() public function testClass() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $snippet = $this->snippetFromClass(Bytes::class); $res = $snippet->invoke('bytes'); $this->assertInstanceOf(Bytes::class, $res->returnVal()); diff --git a/tests/snippets/Spanner/DatabaseTest.php b/tests/snippets/Spanner/DatabaseTest.php index 36d85e57b480..bbc7910928a9 100644 --- a/tests/snippets/Spanner/DatabaseTest.php +++ b/tests/snippets/Spanner/DatabaseTest.php @@ -18,8 +18,12 @@ namespace Google\Cloud\Tests\Snippets\Spanner; use Google\Cloud\Core\Iam\Iam; +use Google\Cloud\Core\Iterator\ItemIterator; use Google\Cloud\Core\LongRunning\LongRunningConnectionInterface; +use Google\Cloud\Core\LongRunning\LongRunningOperation; use Google\Cloud\Dev\Snippet\SnippetTestCase; +use Google\Cloud\Spanner\Admin\Database\V1\DatabaseAdminClient; +use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Database; use Google\Cloud\Spanner\Instance; @@ -50,7 +54,7 @@ class DatabaseTest extends SnippetTestCase public function setUp() { $instance = $this->prophesize(Instance::class); - $instance->name()->willReturn(self::INSTANCE); + $instance->name()->willReturn(InstanceAdminClient::formatInstanceName(self::PROJECT, self::INSTANCE)); $session = $this->prophesize(Session::class); @@ -69,7 +73,7 @@ public function setUp() self::PROJECT, self::DATABASE, $sessionPool->reveal() - ], ['connection', 'operation']); + ], ['connection', 'operation', 'lroConnection']); } private function stubOperation() @@ -83,18 +87,26 @@ private function stubOperation() public function testClass() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $snippet = $this->snippetFromClass(Database::class); $res = $snippet->invoke('database'); $this->assertInstanceOf(Database::class, $res->returnVal()); - $this->assertEquals(self::DATABASE, $res->returnVal()->name()); + $this->assertEquals(self::DATABASE, DatabaseAdminClient::parseDatabaseFromDatabaseName($res->returnVal()->name())); } public function testClassViaInstance() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $snippet = $this->snippetFromClass(Database::class, 1); $res = $snippet->invoke('database'); $this->assertInstanceOf(Database::class, $res->returnVal()); - $this->assertEquals(self::DATABASE, $res->returnVal()->name()); + $this->assertEquals(self::DATABASE, DatabaseAdminClient::parseDatabaseFromDatabaseName($res->returnVal()->name())); } public function testName() @@ -102,7 +114,7 @@ public function testName() $snippet = $this->snippetFromMethod(Database::class, 'name'); $snippet->addLocal('database', $this->database); $res = $snippet->invoke('name'); - $this->assertEquals(self::DATABASE, $res->returnVal()); + $this->assertEquals(self::DATABASE, DatabaseAdminClient::parseDatabaseFromDatabaseName($res->returnVal())); } /** @@ -113,7 +125,7 @@ public function testExists() $snippet = $this->snippetFromMethod(Database::class, 'exists'); $snippet->addLocal('database', $this->database); - $this->connection->getDatabaseDDL(Argument::any()) + $this->connection->getDatabase(Argument::any()) ->shouldBeCalled() ->willReturn(['statements' => []]); @@ -123,6 +135,68 @@ public function testExists() $this->assertEquals('Database exists!', $res->output()); } + /** + * @group spanneradmin + */ + public function testInfo() + { + $db = ['name' => 'foo']; + + $snippet = $this->snippetFromMethod(Database::class, 'info'); + $snippet->addLocal('database', $this->database); + + $this->connection->getDatabase(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($db); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('info'); + $this->assertEquals($db, $res->returnVal()); + $snippet->invoke(); + } + + /** + * @group spanneradmin + */ + public function testReload() + { + $db = ['name' => 'foo']; + + $snippet = $this->snippetFromMethod(Database::class, 'reload'); + $snippet->addLocal('database', $this->database); + + $this->connection->getDatabase(Argument::any()) + ->shouldBeCalledTimes(2) + ->willReturn($db); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('info'); + $this->assertEquals($db, $res->returnVal()); + $snippet->invoke(); + } + + /** + * @group spanneradmin + */ + public function testCreate() + { + $snippet = $this->snippetFromMethod(Database::class, 'create'); + $snippet->addLocal('database', $this->database); + + $this->connection->createDatabase(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'name' => 'my-operation' + ]); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('operation'); + $this->assertInstanceOf(LongRunningOperation::class, $res->returnVal()); + } + /** * @group spanneradmin */ @@ -131,8 +205,11 @@ public function testUpdateDdl() $snippet = $this->snippetFromMethod(Database::class, 'updateDdl'); $snippet->addLocal('database', $this->database); - $this->connection->updateDatabase(Argument::any()) - ->shouldBeCalled(); + $this->connection->updateDatabaseDdl(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'name' => 'my-operation' + ]); $this->database->___setProperty('connection', $this->connection->reveal()); @@ -147,8 +224,11 @@ public function testUpdateDdlBatch() $snippet = $this->snippetFromMethod(Database::class, 'updateDdlBatch'); $snippet->addLocal('database', $this->database); - $this->connection->updateDatabase(Argument::any()) - ->shouldBeCalled(); + $this->connection->updateDatabaseDdl(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'name' => 'my-operation' + ]); $this->database->___setProperty('connection', $this->connection->reveal()); @@ -539,6 +619,7 @@ public function testExecuteBeginSnapshot() $snippet = $this->snippetFromMethod(Database::class, 'execute', 1); $snippet->addLocal('database', $this->database); + $snippet->addUse(SessionPoolInterface::class); $res = $snippet->invoke('result'); $this->assertInstanceOf(Result::class, $res->returnVal()); @@ -637,6 +718,7 @@ public function testReadWithSnapshot() $snippet = $this->snippetFromMethod(Database::class, 'read', 1); $snippet->addLocal('database', $this->database); $snippet->addUse(KeySet::class); + $snippet->addUse(SessionPoolInterface::class); $res = $snippet->invoke('result'); $this->assertInstanceOf(Result::class, $res->returnVal()); @@ -704,6 +786,40 @@ public function testIam() $this->assertInstanceOf(Iam::class, $res->returnVal()); } + public function testResumeOperation() + { + $snippet = $this->snippetFromMagicMethod(Database::class, 'resumeOperation'); + $snippet->addLocal('database', $this->database); + $snippet->addLocal('operationName', 'foo'); + + $res = $snippet->invoke('operation'); + $this->assertInstanceOf(LongRunningOperation::class, $res->returnVal()); + $this->assertEquals('foo', $res->returnVal()->name()); + } + + public function testLongRunningOperations() + { + $snippet = $this->snippetFromMethod(Database::class, 'longRunningOperations'); + $snippet->addLocal('database', $this->database); + + $lroConnection = $this->prophesize(LongRunningConnectionInterface::class); + $lroConnection->operations(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'operations' => [ + [ + 'name' => 'foo' + ] + ] + ]); + + $this->database->___setProperty('lroConnection', $lroConnection->reveal()); + + $res = $snippet->invoke('operations'); + $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); + $this->assertContainsOnlyInstancesOf(LongRunningOperation::class, $res->returnVal()); + } + private function resultGenerator(array $data) { yield $data; diff --git a/tests/snippets/Spanner/DateTest.php b/tests/snippets/Spanner/DateTest.php index 96998c73fa27..0ac50dc03239 100644 --- a/tests/snippets/Spanner/DateTest.php +++ b/tests/snippets/Spanner/DateTest.php @@ -37,6 +37,10 @@ public function setUp() public function testClass() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $snippet = $this->snippetFromClass(Date::class); $res = $snippet->invoke('date'); @@ -52,6 +56,15 @@ public function testClassString() $this->assertEquals($this->date->formatAsString(), $res->output()); } + public function testCreateFromValues() + { + $snippet = $this->snippetFromMethod(Date::class, 'createFromValues'); + $snippet->addUse(Date::class); + + $res = $snippet->invoke('date'); + $this->assertEquals('1995-02-04', $res->returnVal()->formatAsString()); + } + public function testGet() { $snippet = $this->snippetFromMethod(Date::class, 'get'); diff --git a/tests/snippets/Spanner/DurationTest.php b/tests/snippets/Spanner/DurationTest.php index 228c65463153..35747a9fc90e 100644 --- a/tests/snippets/Spanner/DurationTest.php +++ b/tests/snippets/Spanner/DurationTest.php @@ -37,6 +37,10 @@ public function setUp() public function testClass() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $snippet = $this->snippetFromClass(Duration::class); $res = $snippet->invoke('duration'); $this->assertInstanceOf(Duration::class, $res->returnVal()); diff --git a/tests/snippets/Spanner/ConfigurationTest.php b/tests/snippets/Spanner/InstanceConfigurationTest.php similarity index 62% rename from tests/snippets/Spanner/ConfigurationTest.php rename to tests/snippets/Spanner/InstanceConfigurationTest.php index 181b5fecebdf..ca5a59f03f2f 100644 --- a/tests/snippets/Spanner/ConfigurationTest.php +++ b/tests/snippets/Spanner/InstanceConfigurationTest.php @@ -18,15 +18,16 @@ namespace Google\Cloud\Tests\Snippets\Spanner; use Google\Cloud\Dev\Snippet\SnippetTestCase; -use Google\Cloud\Spanner\Configuration; +use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; use Google\Cloud\Spanner\Connection\ConnectionInterface; +use Google\Cloud\Spanner\InstanceConfiguration; use Prophecy\Argument; /** * @group spanner * @group spanneradmin */ -class ConfigurationTest extends SnippetTestCase +class InstanceConfigurationTest extends SnippetTestCase { const PROJECT = 'my-awesome-project'; const CONFIG = 'regional-europe-west'; @@ -37,7 +38,7 @@ class ConfigurationTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->config = \Google\Cloud\Dev\stub(Configuration::class, [ + $this->config = \Google\Cloud\Dev\stub(InstanceConfiguration::class, [ $this->connection->reveal(), self::PROJECT, self::CONFIG @@ -46,33 +47,37 @@ public function setUp() public function testClass() { - $snippet = $this->snippetFromClass(Configuration::class); + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + + $snippet = $this->snippetFromClass(InstanceConfiguration::class); $res = $snippet->invoke('configuration'); - $this->assertInstanceOf(Configuration::class, $res->returnVal()); - $this->assertEquals(self::CONFIG, $res->returnVal()->name()); + $this->assertInstanceOf(InstanceConfiguration::class, $res->returnVal()); + $this->assertEquals(InstanceAdminClient::formatInstanceConfigName(self::PROJECT, self::CONFIG), $res->returnVal()->name()); } public function testName() { - $snippet = $this->snippetFromMethod(Configuration::class, 'name'); + $snippet = $this->snippetFromMethod(InstanceConfiguration::class, 'name'); $snippet->addLocal('configuration', $this->config); $res = $snippet->invoke('name'); - $this->assertEquals(self::CONFIG, $res->returnVal()); + $this->assertEquals(InstanceAdminClient::formatInstanceConfigName(self::PROJECT, self::CONFIG), $res->returnVal()); } public function testInfo() { - $snippet = $this->snippetFromMethod(Configuration::class, 'info'); + $snippet = $this->snippetFromMethod(InstanceConfiguration::class, 'info'); $snippet->addLocal('configuration', $this->config); $info = [ - 'name' => 'projects/'. self::PROJECT .'/instanceConfigs/'. self::CONFIG, + 'name' => InstanceAdminClient::formatInstanceConfigName(self::PROJECT, self::CONFIG), 'displayName' => self::CONFIG ]; - $this->connection->getConfig(Argument::any()) + $this->connection->getInstanceConfig(Argument::any()) ->shouldBeCalled() ->willReturn($info); @@ -84,13 +89,13 @@ public function testInfo() public function testExists() { - $snippet = $this->snippetFromMethod(Configuration::class, 'exists'); + $snippet = $this->snippetFromMethod(InstanceConfiguration::class, 'exists'); $snippet->addLocal('configuration', $this->config); - $this->connection->getConfig(Argument::any()) + $this->connection->getInstanceConfig(Argument::any()) ->shouldBeCalled() ->willReturn([ - 'name' => 'projects/'. self::PROJECT .'/instanceConfigs/'. self::CONFIG, + 'name' => InstanceAdminClient::formatInstanceConfigName(self::PROJECT, self::CONFIG), 'displayName' => self::CONFIG ]); @@ -103,14 +108,14 @@ public function testExists() public function testReload() { $info = [ - 'name' => 'projects/'. self::PROJECT .'/instanceConfigs/'. self::CONFIG, + 'name' => InstanceAdminClient::formatInstanceConfigName(self::PROJECT, self::CONFIG), 'displayName' => self::CONFIG ]; - $snippet = $this->snippetFromMethod(Configuration::class, 'reload'); + $snippet = $this->snippetFromMethod(InstanceConfiguration::class, 'reload'); $snippet->addLocal('configuration', $this->config); - $this->connection->getConfig(Argument::any()) + $this->connection->getInstanceConfig(Argument::any()) ->shouldBeCalled() ->willReturn($info); diff --git a/tests/snippets/Spanner/InstanceTest.php b/tests/snippets/Spanner/InstanceTest.php index e30f2975c992..d9470db79108 100644 --- a/tests/snippets/Spanner/InstanceTest.php +++ b/tests/snippets/Spanner/InstanceTest.php @@ -22,9 +22,12 @@ use Google\Cloud\Core\LongRunning\LongRunningConnectionInterface; use Google\Cloud\Core\LongRunning\LongRunningOperation; use Google\Cloud\Dev\Snippet\SnippetTestCase; +use Google\Cloud\Spanner\Admin\Database\V1\DatabaseAdminClient; +use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Database; use Google\Cloud\Spanner\Instance; +use Google\Cloud\Spanner\InstanceConfiguration; use Prophecy\Argument; /** @@ -49,15 +52,41 @@ public function setUp() [], self::PROJECT, self::INSTANCE - ]); + ], ['connection', 'lroConnection']); } public function testClass() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $snippet = $this->snippetFromClass(Instance::class); $res = $snippet->invoke('instance'); $this->assertInstanceOf(Instance::class, $res->returnVal()); - $this->assertEquals(self::INSTANCE, $res->returnVal()->name()); + $this->assertEquals(InstanceAdminClient::formatInstanceName(self::PROJECT, self::INSTANCE), $res->returnVal()->name()); + } + + /** + * @group spanneradmin + */ + public function testCreate() + { + $config = $this->prophesize(InstanceConfiguration::class); + $config->name()->willReturn(InstanceAdminClient::formatInstanceConfigName(self::PROJECT, 'foo')); + + $snippet = $this->snippetFromMethod(Instance::class, 'create'); + $snippet->addLocal('configuration', $config->reveal()); + $snippet->addLocal('instance', $this->instance); + + $this->connection->createInstance(Argument::any()) + ->shouldBeCalled() + ->willReturn(['name' => 'operations/foo']); + + $this->instance->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('operation'); + $this->assertInstanceOf(LongRunningOperation::class, $res->returnVal()); } public function testName() @@ -66,7 +95,7 @@ public function testName() $snippet->addLocal('instance', $this->instance); $res = $snippet->invoke('name'); - $this->assertEquals(self::INSTANCE, $res->returnVal()); + $this->assertEquals(InstanceAdminClient::formatInstanceName(self::PROJECT, self::INSTANCE), $res->returnVal()); } public function testInfo() @@ -136,16 +165,12 @@ public function testUpdate() $snippet = $this->snippetFromMethod(Instance::class, 'update'); $snippet->addLocal('instance', $this->instance); - $this->connection->getInstance(Argument::any()) - ->shouldBeCalledTimes(1) + $this->connection->updateInstance(Argument::any()) + ->shouldBeCalled() ->willReturn([ - 'displayName' => 'foo', - 'nodeCount' => 1 + 'name' => 'my-operation' ]); - $this->connection->updateInstance(Argument::any()) - ->shouldBeCalled(); - $this->instance->___setProperty('connection', $this->connection->reveal()); $snippet->invoke(); } @@ -168,11 +193,14 @@ public function testCreateDatabase() $snippet->addLocal('instance', $this->instance); $this->connection->createDatabase(Argument::any()) - ->shouldBeCalled(); + ->shouldBeCalled() + ->willReturn([ + 'name' => 'my-operation' + ]); $this->instance->___setProperty('connection', $this->connection->reveal()); - $res = $snippet->invoke('database'); + $res = $snippet->invoke('operation'); $this->assertInstanceOf(LongRunningOperation::class, $res->returnVal()); } @@ -183,7 +211,7 @@ public function testDatabase() $res = $snippet->invoke('database'); $this->assertInstanceOf(Database::class, $res->returnVal()); - $this->assertEquals(self::DATABASE, $res->returnVal()->name()); + $this->assertEquals(self::DATABASE, DatabaseAdminClient::parseDatabaseFromDatabaseName($res->returnVal()->name())); } public function testDatabases() @@ -196,7 +224,7 @@ public function testDatabases() ->willReturn([ 'databases' => [ [ - 'name' => 'projects/'. self::PROJECT .'/instances/'. self::INSTANCE .'/databases/'. self::DATABASE + 'name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT, self::INSTANCE, self::DATABASE) ] ] ]); @@ -217,4 +245,38 @@ public function testIam() $res = $snippet->invoke('iam'); $this->assertInstanceOf(Iam::class, $res->returnVal()); } + + public function testResumeOperation() + { + $snippet = $this->snippetFromMagicMethod(Instance::class, 'resumeOperation'); + $snippet->addLocal('instance', $this->instance); + $snippet->addLocal('operationName', 'foo'); + + $res = $snippet->invoke('operation'); + $this->assertInstanceOf(LongRunningOperation::class, $res->returnVal()); + $this->assertEquals('foo', $res->returnVal()->name()); + } + + public function testLongRunningOperations() + { + $snippet = $this->snippetFromMethod(Instance::class, 'longRunningOperations'); + $snippet->addLocal('instance', $this->instance); + + $lroConnection = $this->prophesize(LongRunningConnectionInterface::class); + $lroConnection->operations(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'operations' => [ + [ + 'name' => 'foo' + ] + ] + ]); + + $this->instance->___setProperty('lroConnection', $lroConnection->reveal()); + + $res = $snippet->invoke('operations'); + $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); + $this->assertContainsOnlyInstancesOf(LongRunningOperation::class, $res->returnVal()); + } } diff --git a/tests/snippets/Spanner/KeyRangeTest.php b/tests/snippets/Spanner/KeyRangeTest.php index 869d3694a6bb..2efbb6f36c2b 100644 --- a/tests/snippets/Spanner/KeyRangeTest.php +++ b/tests/snippets/Spanner/KeyRangeTest.php @@ -34,12 +34,35 @@ public function setUp() public function testClass() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $snippet = $this->snippetFromClass(KeyRange::class); $snippet->addUse(KeyRange::class); $res = $snippet->invoke('range'); $this->assertInstanceOf(KeyRange::class, $res->returnVal()); } + public function testPrefixMatch() + { + $key = ['foo']; + + $range = new KeyRange([ + 'start' => $key, + 'end' => $key, + 'startType' => KeyRange::TYPE_CLOSED, + 'endType' => KeyRange::TYPE_CLOSED, + ]); + + $snippet = $this->snippetFromMethod(KeyRange::class, 'prefixMatch'); + $snippet->addLocal('key', $key); + $snippet->addUse(KeyRange::class); + $res = $snippet->invoke('range'); + + $this->assertEquals($range, $res->returnVal()); + } + public function testStart() { $this->range->setStart(KeyRange::TYPE_OPEN, ['Bob']); diff --git a/tests/snippets/Spanner/KeySetTest.php b/tests/snippets/Spanner/KeySetTest.php index c7a2904c53f6..af16d08ce719 100644 --- a/tests/snippets/Spanner/KeySetTest.php +++ b/tests/snippets/Spanner/KeySetTest.php @@ -37,6 +37,10 @@ public function setUp() public function testClass() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $snippet = $this->snippetFromClass(KeySet::class); $res = $snippet->invoke('keySet'); $this->assertInstanceOf(KeySet::class, $res->returnVal()); diff --git a/tests/snippets/Spanner/ResultTest.php b/tests/snippets/Spanner/ResultTest.php index 9315b1e56a25..766525d0c176 100644 --- a/tests/snippets/Spanner/ResultTest.php +++ b/tests/snippets/Spanner/ResultTest.php @@ -48,6 +48,8 @@ public function setUp() ->willReturn($this->prophesize(Snapshot::class)->reveal()); $result->transaction() ->willReturn($this->prophesize(Transaction::class)->reveal()); + $result->stats() + ->willReturn([]); $this->result = $result->reveal(); $database->execute(Argument::any()) ->willReturn($this->result); @@ -56,6 +58,10 @@ public function setUp() public function testClass() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $snippet = $this->snippetFromClass(Result::class); $snippet->replace('$database =', '//$database ='); $snippet->addLocal('database', $this->database); @@ -81,12 +87,22 @@ public function testMetadata() public function testStats() { - $snippet = $this->snippetFromMethod(Result::class, 'metadata'); + $snippet = $this->snippetFromMethod(Result::class, 'stats'); $snippet->addLocal('result', $this->result); - $res = $snippet->invoke('metadata'); + $res = $snippet->invoke('stats'); $this->assertInternalType('array', $res->returnVal()); } + public function testQueryWithStats() + { + $db = $this->prophesize(Database::class); + $db->execute(Argument::any(), ['queryMode' => 'PROFILE']); + + $snippet = $this->snippetFromMethod(Result::class, 'stats', 1); + $snippet->addLocal('database', $db->reveal()); + $snippet->invoke(); + } + public function testSnapshot() { $snippet = $this->snippetFromMethod(Result::class, 'snapshot'); diff --git a/tests/snippets/Spanner/Session/CacheSessionPoolTest.php b/tests/snippets/Spanner/Session/CacheSessionPoolTest.php new file mode 100644 index 000000000000..1849221e57af --- /dev/null +++ b/tests/snippets/Spanner/Session/CacheSessionPoolTest.php @@ -0,0 +1,42 @@ +markTestSkipped('Must have the grpc extension installed to run this test.'); + } + + $snippet = $this->snippetFromClass(CacheSessionPool::class); + $snippet->replace('$cache =', '//$cache ='); + $snippet->addLocal('cache', new MemoryCacheItemPool); + $res = $snippet->invoke('database'); + $this->assertInstanceOf(Database::class, $res->returnVal()); + } +} diff --git a/tests/snippets/Spanner/SnapshotTest.php b/tests/snippets/Spanner/SnapshotTest.php index 11b24f6e9cbd..52779a8aeac8 100644 --- a/tests/snippets/Spanner/SnapshotTest.php +++ b/tests/snippets/Spanner/SnapshotTest.php @@ -49,8 +49,10 @@ public function setUp() $this->snapshot = \Google\Cloud\Dev\stub(Snapshot::class, [ $operation->reveal(), $session->reveal(), - self::TRANSACTION, - new Timestamp(new \DateTime) + [ + 'id' => self::TRANSACTION, + 'readTimestamp' => new Timestamp(new \DateTime) + ] ], ['operation']); } @@ -69,6 +71,10 @@ private function stubOperation($stub = null) public function testClass() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $database = $this->prophesize(Database::class); $database->snapshot()->shouldBeCalled()->willReturn('foo'); diff --git a/tests/snippets/Spanner/SpannerClientTest.php b/tests/snippets/Spanner/SpannerClientTest.php index 125a6c9f57af..2fa48f7dc296 100644 --- a/tests/snippets/Spanner/SpannerClientTest.php +++ b/tests/snippets/Spanner/SpannerClientTest.php @@ -21,13 +21,14 @@ use Google\Cloud\Core\Iterator\ItemIterator; use Google\Cloud\Core\LongRunning\LongRunningOperation; use Google\Cloud\Dev\Snippet\SnippetTestCase; +use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; use Google\Cloud\Spanner\Bytes; -use Google\Cloud\Spanner\Configuration; use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Database; use Google\Cloud\Spanner\Date; use Google\Cloud\Spanner\Duration; use Google\Cloud\Spanner\Instance; +use Google\Cloud\Spanner\InstanceConfiguration; use Google\Cloud\Spanner\KeyRange; use Google\Cloud\Spanner\KeySet; use Google\Cloud\Spanner\SpannerClient; @@ -39,7 +40,8 @@ */ class SpannerClientTest extends SnippetTestCase { - const CONFIG = 'Foo'; + const PROJECT = 'my-awesome-project'; + const CONFIG = 'foo'; const INSTANCE = 'my-instance'; private $client; @@ -47,6 +49,10 @@ class SpannerClientTest extends SnippetTestCase public function setUp() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $this->connection = $this->prophesize(ConnectionInterface::class); $this->client = \Google\Cloud\Dev\stub(SpannerClient::class); $this->client->___setProperty('connection', $this->connection->reveal()); @@ -62,43 +68,43 @@ public function testClass() /** * @group spanneradmin */ - public function testConfigurations() + public function testInstanceConfigurations() { - $this->connection->listConfigs(Argument::any()) + $this->connection->listInstanceConfigs(Argument::any()) ->shouldBeCalled() ->willReturn([ 'instanceConfigs' => [ - ['name' => 'projects/my-awesome-projects/instanceConfigs/Foo'], - ['name' => 'projects/my-awesome-projects/instanceConfigs/Bar'], + ['name' => 'projects/my-awesome-projects/instanceConfigs/foo'], + ['name' => 'projects/my-awesome-projects/instanceConfigs/bar'], ] ]); $this->client->___setProperty('connection', $this->connection->reveal()); - $snippet = $this->snippetFromMethod(SpannerClient::class, 'configurations'); + $snippet = $this->snippetFromMethod(SpannerClient::class, 'instanceConfigurations'); $snippet->addLocal('spanner', $this->client); $res = $snippet->invoke('configurations'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); - $this->assertInstanceOf(Configuration::class, $res->returnVal()->current()); - $this->assertEquals('Foo', $res->returnVal()->current()->name()); + $this->assertInstanceOf(InstanceConfiguration::class, $res->returnVal()->current()); + $this->assertEquals('projects/my-awesome-projects/instanceConfigs/foo', $res->returnVal()->current()->name()); } /** * @group spanneradmin */ - public function testConfiguration() + public function testInstanceConfiguration() { $configName = 'foo'; - $snippet = $this->snippetFromMethod(SpannerClient::class, 'configuration'); + $snippet = $this->snippetFromMethod(SpannerClient::class, 'instanceConfiguration'); $snippet->addLocal('spanner', $this->client); $snippet->addLocal('configurationName', self::CONFIG); $res = $snippet->invoke('configuration'); - $this->assertInstanceOf(Configuration::class, $res->returnVal()); - $this->assertEquals(self::CONFIG, $res->returnVal()->name()); + $this->assertInstanceOf(InstanceConfiguration::class, $res->returnVal()); + $this->assertEquals(InstanceAdminClient::formatInstanceConfigName(self::PROJECT, $configName), $res->returnVal()->name()); } /** @@ -108,7 +114,7 @@ public function testCreateInstance() { $snippet = $this->snippetFromMethod(SpannerClient::class, 'createInstance'); $snippet->addLocal('spanner', $this->client); - $snippet->addLocal('configuration', $this->client->configuration(self::CONFIG)); + $snippet->addLocal('configuration', $this->client->instanceConfiguration(self::CONFIG)); $this->connection->createInstance(Argument::any()) ->shouldBeCalled() @@ -130,7 +136,7 @@ public function testInstance() $res = $snippet->invoke('instance'); $this->assertInstanceOf(Instance::class, $res->returnVal()); - $this->assertEquals(self::INSTANCE, $res->returnVal()->name()); + $this->assertEquals(InstanceAdminClient::formatInstanceName(self::PROJECT, self::INSTANCE), $res->returnVal()->name()); } /** @@ -145,8 +151,8 @@ public function testInstances() ->shouldBeCalled() ->willReturn([ 'instances' => [ - ['name' => 'projects/my-awesome-project/instances/'. self::INSTANCE], - ['name' => 'projects/my-awesome-project/instances/Bar'] + ['name' => InstanceAdminClient::formatInstanceName(self::PROJECT, self::INSTANCE)], + ['name' => InstanceAdminClient::formatInstanceName(self::PROJECT, 'bar')] ] ]); @@ -155,7 +161,7 @@ public function testInstances() $res = $snippet->invoke('instances'); $this->assertInstanceOf(ItemIterator::class, $res->returnVal()); $this->assertInstanceOf(Instance::class, $res->returnVal()->current()); - $this->assertEquals(self::INSTANCE, $res->returnVal()->current()->name()); + $this->assertEquals(InstanceAdminClient::formatInstanceName(self::PROJECT, self::INSTANCE), $res->returnVal()->current()->name()); } public function testConnect() @@ -253,7 +259,7 @@ public function testDuration() public function testResumeOperation() { - $snippet = $this->snippetFromMethod(SpannerClient::class, 'resumeOperation'); + $snippet = $this->snippetFromMagicMethod(SpannerClient::class, 'resumeOperation'); $snippet->addLocal('spanner', $this->client); $snippet->addLocal('operationName', 'operations/foo'); diff --git a/tests/snippets/Spanner/TimestampTest.php b/tests/snippets/Spanner/TimestampTest.php index a96512fe0d11..1d51dcba2d53 100644 --- a/tests/snippets/Spanner/TimestampTest.php +++ b/tests/snippets/Spanner/TimestampTest.php @@ -36,6 +36,10 @@ public function setUp() public function testClass() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $snippet = $this->snippetFromClass(Timestamp::class); $res = $snippet->invoke('timestamp'); $this->assertInstanceOf(Timestamp::class, $res->returnVal()); diff --git a/tests/snippets/Spanner/TransactionTest.php b/tests/snippets/Spanner/TransactionTest.php index 881adc9f9ca1..2c6596ef9e08 100644 --- a/tests/snippets/Spanner/TransactionTest.php +++ b/tests/snippets/Spanner/TransactionTest.php @@ -67,6 +67,10 @@ private function stubOperation($stub = null) public function testClass() { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + $database = $this->prophesize(Database::class); $database->runTransaction(Argument::type('callable'))->shouldBeCalled(); diff --git a/tests/snippets/bootstrap.php b/tests/snippets/bootstrap.php index 298292891bbb..3cabe2e11455 100644 --- a/tests/snippets/bootstrap.php +++ b/tests/snippets/bootstrap.php @@ -30,6 +30,8 @@ if (!empty($uncovered)) { echo sprintf("\033[31mNOTICE: %s uncovered snippets! \033[0m See build/snippets-uncovered.json for a report.\n", count($uncovered)); - exit(1); + if (extension_loaded('grpc')) { + exit(1); + } } }); diff --git a/tests/system/Spanner/AdminTest.php b/tests/system/Spanner/AdminTest.php index 545deb89989a..39ed97792f44 100644 --- a/tests/system/Spanner/AdminTest.php +++ b/tests/system/Spanner/AdminTest.php @@ -22,6 +22,7 @@ use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; use Google\Cloud\Spanner\Database; use Google\Cloud\Spanner\Instance; +use Google\Cloud\Spanner\InstanceConfiguration; /** * @group spanner @@ -34,15 +35,15 @@ public function testInstance() $instances = $client->instances(); $instance = array_filter(iterator_to_array($instances), function ($instance) { - return $instance->name() === self::INSTANCE_NAME; + return $this->parseName($instance->name()) === self::INSTANCE_NAME; }); - $this->assertInstanceOf(Instance::class, $instance[0]); + $this->assertInstanceOf(Instance::class, current($instance)); $instance = self::$instance; $this->assertTrue($instance->exists()); - $this->assertEquals($instance->name(), $this->parseName($instance->info()['name'])); - $this->assertEquals($instance->name(), $this->parseName($instance->reload()['name'])); + $this->assertEquals($instance->name(), $instance->info()['name']); + $this->assertEquals($instance->name(), $instance->reload()['name']); $this->assertEquals(Instance::STATE_READY, $instance->state()); @@ -73,7 +74,7 @@ public function testDatabase() $databases = $instance->databases(); $database = array_filter(iterator_to_array($databases), function ($db) use ($dbName) { - return $db->name() === $dbName; + return $this->parseDbName($db->name()) === $dbName; }); $this->assertInstanceOf(Database::class, current($database)); @@ -90,6 +91,27 @@ public function testDatabase() $this->assertEquals($db->ddl()[0], $stmt); } + public function testConfigurations() + { + $client = self::$client; + + $configurations = $client->instanceConfigurations(); + + $this->assertContainsOnly(InstanceConfiguration::class, $configurations); + + $res = iterator_to_array($configurations); + $firstConfigName = $res[0]->name(); + + $config = $client->instanceConfiguration($firstConfigName); + + $this->assertInstanceOf(InstanceConfiguration::class, $config); + $this->assertEquals($firstConfigName, $config->name()); + + $this->assertTrue($config->exists()); + $this->assertEquals($config->name(), $config->info()['name']); + $this->assertEquals($config->name(), $config->reload()['name']); + } + private function parseName($name) { return InstanceAdminClient::parseInstanceFromInstanceName($name); diff --git a/tests/system/Spanner/ConfigurationTest.php b/tests/system/Spanner/ConfigurationTest.php deleted file mode 100644 index 94ded41522f1..000000000000 --- a/tests/system/Spanner/ConfigurationTest.php +++ /dev/null @@ -1,53 +0,0 @@ -configurations(); - - $this->assertContainsOnly(Configuration::class, $configurations); - - $res = iterator_to_array($configurations); - $firstConfigName = $res[0]->name(); - - $config = $client->configuration($firstConfigName); - - $this->assertInstanceOf(Configuration::class, $config); - $this->assertEquals($firstConfigName, $config->name()); - - $this->assertTrue($config->exists()); - $this->assertEquals($config->name(), $this->parseName($config->info()['name'])); - $this->assertEquals($config->name(), $this->parseName($config->reload()['name'])); - } - - private function parseName($name) - { - return InstanceAdminClient::parseInstanceConfigFromInstanceConfigName($name); - } -} diff --git a/tests/system/Spanner/OperationsTest.php b/tests/system/Spanner/OperationsTest.php index f5113845eec5..9214fdedbd1d 100644 --- a/tests/system/Spanner/OperationsTest.php +++ b/tests/system/Spanner/OperationsTest.php @@ -17,6 +17,7 @@ namespace Google\Cloud\Tests\System\Spanner; +use Google\Cloud\Core\Exception\ServiceException; use Google\Cloud\Spanner\Date; use Google\Cloud\Spanner\Timestamp; @@ -25,51 +26,62 @@ */ class OperationsTest extends SpannerTestCase { - private $id = 12345; + private static $id1; + private static $id2; + private static $name1; + private static $name2; + + public static function setUpBeforeClass() + { + self::$id1 = rand(1000,9999); + self::$id2 = rand(1,999); + self::$name1 = uniqid(self::TESTING_PREFIX); + self::$name2 = uniqid(self::TESTING_PREFIX); + + parent::setUpBeforeClass(); + + self::$database->insert(self::TEST_TABLE_NAME, [ + 'id' => self::$id1, + 'name' => self::$name1, + 'birthday' => new Date(new \DateTime('2000-01-01')) + ]); + } public function testInsert() { $db = self::$database; + $res = $db->insert(self::TEST_TABLE_NAME, [ - 'id' => $this->id, - 'name' => 'Bob', - 'birthday' => self::$client->date(new \DateTime('2000-01-01')) + 'id' => self::$id2, + 'name' => self::$name2, + 'birthday' => new Date(new \DateTime('2000-01-01')) ]); $this->assertInstanceOf(Timestamp::class, $res); } - /** - * @depends testInsert - */ public function testExecute() { $db = self::$database; $row = $this->getRow(); - $this->assertEquals($this->id, $row['id']); + $this->assertEquals(self::$id2, $row['id']); } - /** - * @depends testInsert - */ public function testRead() { $db = self::$database; $keySet = self::$client->keySet([ - 'keys' => [$this->id] + 'keys' => [self::$id2] ]); $columns = ['id', 'name']; $res = $db->read(self::TEST_TABLE_NAME, $keySet, $columns); $row = $res->rows()->current(); - $this->assertEquals($this->id, $row['id']); + $this->assertEquals(self::$id2, $row['id']); } - /** - * @depends testInsert - */ public function testUpdate() { $db = self::$database; @@ -82,14 +94,11 @@ public function testUpdate() $this->assertEquals('Doug', $row['name']); } - /** - * @depends testInsert - */ public function testInsertOrUpdate() { $db = self::$database; $db->insertOrUpdate('Users', [ - 'id' => $this->id, + 'id' => self::$id2, 'name' => 'Dave', 'birthday' => new Date(new \DateTime('1990-01-01')) ]); @@ -98,14 +107,11 @@ public function testInsertOrUpdate() $this->assertEquals('Dave', $row['name']); } - /** - * @depends testInsert - */ public function testReplace() { $db = self::$database; $db->replace('Users', [ - 'id' => $this->id, + 'id' => self::$id2, 'name' => 'John', 'birthday' => new Date(new \DateTime('1990-01-01')) ]); @@ -114,26 +120,84 @@ public function testReplace() $this->assertEquals('John', $row['name']); } - /** - * @depends testInsert - */ public function testDelete() { $db = self::$database; $keySet = self::$client->keySet([ - 'keys' => [$this->id] + 'keys' => [self::$id2] ]); $db->delete(self::TEST_TABLE_NAME, $keySet); $this->assertNull($this->getRow()); } + public function testEmptyRead() + { + $db = self::$database; + + $keySet = self::$client->keySet(['keys' => [99999]]); + + $res = $db->read(self::TEST_TABLE_NAME, $keySet, ['id','name']); + $this->assertEmpty(iterator_to_array($res->rows())); + } + + public function testEmptyReadOnIndex() + { + $db = self::$database; + + $keySet = self::$client->keySet(['keys' => [99999]]); + + $res = $db->read(self::TEST_TABLE_NAME, $keySet, ['id','name'], [ + 'index' => self::TEST_INDEX_NAME + ]); + + $this->assertEmpty(iterator_to_array($res->rows())); + } + + public function testReadSingleKeyFromIndex() + { + $db = self::$database; + + $keySet = self::$client->keySet(['keys' => [self::$name1]]); + + $res = $db->read(self::TEST_TABLE_NAME, $keySet, ['name'], [ + 'index' => self::TEST_INDEX_NAME + ]); + + $this->assertEquals(self::$name1, $res->rows()->current()['name']); + } + + public function testReadNonExistentSingleKey() + { + $db = self::$database; + + $keySet = self::$client->keySet([ + 'keys' => [99999] + ]); + + $res = $db->read(self::TEST_TABLE_NAME, $keySet, ['id','name']); + $this->assertEmpty(iterator_to_array($res->rows())); + } + + public function testReadNonExistentSingleKeyFromIndex() + { + $db = self::$database; + + $keySet = self::$client->keySet(['keys' => ['foobar']]); + + $res = $db->read(self::TEST_TABLE_NAME, $keySet, ['name'], [ + 'index' => self::TEST_INDEX_NAME + ]); + + $this->assertEmpty(iterator_to_array($res->rows())); + } + private function getRow() { $db = self::$database; $res = $db->execute('SELECT * FROM '. self::TEST_TABLE_NAME .' WHERE id=@id', [ 'parameters' => [ - 'id' => $this->id + 'id' => self::$id2 ] ]); diff --git a/tests/system/Spanner/SpannerTestCase.php b/tests/system/Spanner/SpannerTestCase.php index 264a5542a6dd..61c6f406182d 100644 --- a/tests/system/Spanner/SpannerTestCase.php +++ b/tests/system/Spanner/SpannerTestCase.php @@ -27,7 +27,9 @@ class SpannerTestCase extends \PHPUnit_Framework_TestCase { const TESTING_PREFIX = 'gcloud_testing_'; const INSTANCE_NAME = 'google-cloud-php-system-tests'; + const TEST_TABLE_NAME = 'Users'; + const TEST_INDEX_NAME = 'uniqueIndex'; protected static $client; protected static $instance; @@ -57,14 +59,18 @@ public static function setUpBeforeClass() self::$deletionQueue[] = function() use ($db) { $db->drop(); }; - $op = $db->updateDdl( + $db->updateDdl( 'CREATE TABLE '. self::TEST_TABLE_NAME .' ( id INT64 NOT NULL, name STRING(MAX) NOT NULL, birthday DATE NOT NULL ) PRIMARY KEY (id)' - ); - $op->pollUntilComplete(); + )->pollUntilComplete(); + + $db->updateDdl( + 'CREATE UNIQUE INDEX '. self::TEST_INDEX_NAME .' + ON '. self::TEST_TABLE_NAME .' (name)' + )->pollUntilComplete(); self::$database = $db; } diff --git a/tests/system/Spanner/TransactionTest.php b/tests/system/Spanner/TransactionTest.php index 1ce4f5825a81..66f3ff1613f0 100644 --- a/tests/system/Spanner/TransactionTest.php +++ b/tests/system/Spanner/TransactionTest.php @@ -18,12 +18,29 @@ namespace Google\Cloud\Tests\System\Spanner; use Google\Cloud\Spanner\Date; +use Google\Cloud\Spanner\KeySet; +use Google\Cloud\Spanner\Timestamp; /** - * @group spanner + * @group spannerz */ class TransactionTest extends SpannerTestCase { + private static $row = []; + + public static function setUpBeforeClass() + { + parent::setUpBeforeClass(); + + self::$row = [ + 'id' => rand(1000,9999), + 'name' => uniqid(self::TESTING_PREFIX), + 'birthday' => new Date(new \DateTime('2000-01-01')) + ]; + echo 'inserting row'.PHP_EOL; + self::$database->insert(self::TEST_TABLE_NAME, self::$row); + } + public function testRunTransaction() { $db = self::$database; @@ -43,4 +60,72 @@ public function testRunTransaction() $t->rollback(); }); } + + public function testStrongRead() + { + $db = self::$database; + + $snapshot = $db->snapshot([ + 'strong' => true, + 'returnReadTimestamp' => true + ]); + + list($keySet, $cols) = $this->readArgs(); + $res = $snapshot->read(self::TEST_TABLE_NAME, $keySet, $cols); + + $row = $res->rows()->current(); + + $this->assertEquals(self::$row, $row); + $this->assertInstanceOf(Timestamp::class, $snapshot->readTimestamp()); + } + + public function testExactTimestampRead() + { + $db = self::$database; + + $ts = new Timestamp(new \DateTimeImmutable); + + $row = $db->execute('SELECT * FROM '. self::TEST_TABLE_NAME .' WHERE id = @id', [ + 'parameters' => ['id' => self::$row['id']] + ])->rows()->current(); + $row['name'] = uniqid(self::TESTING_PREFIX); + + $db->update(self::TEST_TABLE_NAME, $row); + sleep(10); + + $snapshot = $db->snapshot([ + 'returnReadTimestamp' => true, + 'readTimestamp' => $ts + ]); + + list($keySet, $cols) = $this->readArgs(); + + echo "Cached row data (should match snapshot result)".PHP_EOL; + print_r(self::$row); + + $res = $snapshot->read(self::TEST_TABLE_NAME, $keySet, $cols)->rows(); + echo PHP_EOL."Snapshot Result". PHP_EOL; + print_r(iterator_to_array($res)); + + echo PHP_EOL."Database Result". PHP_EOL; + print_r(iterator_to_array($db->read(self::TEST_TABLE_NAME, $keySet, $cols)->rows())); + exit; + $row = $res->current(); + + $this->assertEquals($ts->get(), $snapshot->readTimestamp()->get()); + $this->assertEquals($row, self::$row); + + // Reset to previous state. + $db->update(self::TEST_TABLE_NAME, self::$row); + } + + private function readArgs() + { + return [ + new KeySet([ + 'keys' => [self::$row['id']] + ]), + array_keys(self::$row) + ]; + } } diff --git a/tests/unit/Spanner/BytesTest.php b/tests/unit/Spanner/BytesTest.php index d224738f96e2..d91b19ccfd34 100644 --- a/tests/unit/Spanner/BytesTest.php +++ b/tests/unit/Spanner/BytesTest.php @@ -15,7 +15,7 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Spanner\Bytes; diff --git a/tests/unit/Spanner/Connection/GrpcTest.php b/tests/unit/Spanner/Connection/GrpcTest.php new file mode 100644 index 000000000000..9b486e493efb --- /dev/null +++ b/tests/unit/Spanner/Connection/GrpcTest.php @@ -0,0 +1,359 @@ +markTestSkipped('Must have the grpc extension installed to run this test.'); + } + + $this->requestWrapper = $this->prophesize(GrpcRequestWrapper::class); + $this->successMessage = 'success'; + } + + /** + * @dataProvider methodProvider + */ + public function testCallBasicMethods($method, $args, $expectedArgs, $return = null, $result = '') + { + $this->requestWrapper->send( + Argument::type('callable'), + $expectedArgs, + Argument::type('array') + )->willReturn($return ?: $this->successMessage); + + $grpc = new Grpc(); + $grpc->setRequestWrapper($this->requestWrapper->reveal()); + + $this->assertEquals($result !== '' ? $result : $this->successMessage, $grpc->$method($args)); + } + + public function methodProvider() + { + $codec = new PhpArray; + + $configName = 'test-config'; + $instanceName = 'test-instance'; + $policy = ['foo' => 'bar']; + $permissions = ['permission1','permission2']; + $databaseName = 'test-database'; + $sessionName = 'test-session'; + $transactionName = 'test-transaction'; + + $instanceArgs = [ + 'name' => $instanceName, + 'config' => $configName, + 'displayName' => $instanceName, + 'nodeCount' => 1, + 'state' => \google\spanner\admin\instance\v1\Instance\State::CREATING, + 'labels' => [] + ]; + + $instance = (new \google\spanner\admin\instance\v1\Instance) + ->deserialize(array_filter([ + 'labels' => $this->formatLabelsForApi([]) + ] + $instanceArgs), $codec); + + $lro = $this->prophesize(OperationResponse::class)->reveal(); + + $mask = array_keys($instance->serialize(new PhpArray([], false))); + $fieldMask = (new \google\protobuf\FieldMask())->deserialize(['paths' => $mask], $codec); + + $tableName = 'foo'; + + $createStmt = 'CREATE TABLE '. $tableName; + $sql = 'SELECT * FROM '. $tableName; + + $transactionSelector = (new TransactionSelector) + ->deserialize(['id' => $transactionName], $codec); + + $mapper = new ValueMapper(false); + $mapped = $mapper->formatParamsForExecuteSql(['foo' => 'bar']); + + $expectedParams = (new \google\protobuf\Struct) + ->deserialize($this->formatStructForApi($mapped['params']), $codec); + + $expectedParamTypes = $mapped['paramTypes']; + foreach ($expectedParamTypes as $key => $param) { + $expectedParamTypes[$key] = (new Type) + ->deserialize($param, $codec); + } + + $columns = ['id', 'name']; + $keySetArgs = []; + $keySet = (new KeySet) + ->deserialize($keySetArgs, $codec); + + $readWriteTransactionArgs = ['readWrite' => []]; + $readWriteTransactionOptions = new TransactionOptions; + $rw = new TransactionOptions\ReadWrite; + $readWriteTransactionOptions->setReadWrite($rw); + + $ts = (new \DateTime)->format('Y-m-d\TH:i:s.u\Z'); + $readOnlyTransactionArgs = [ + 'readOnly' => [ + 'minReadTimestamp' => $ts, + 'readTimestamp' => $ts + ] + ]; + + $roObjArgs = $readOnlyTransactionArgs; + $roObjArgs['readOnly']['minReadTimestamp'] = $this->formatTimestampForApi($ts); + $roObjArgs['readOnly']['readTimestamp'] = $this->formatTimestampForApi($ts); + $readOnlyTransactionOptions = new TransactionOptions; + $ro = (new TransactionOptions\ReadOnly) + ->deserialize($roObjArgs['readOnly'], $codec); + + $readOnlyTransactionOptions->setReadOnly($ro); + + $insertMutations = [ + [ + 'insert' => [ + 'table' => $tableName, + 'columns' => ['foo'], + 'values' => ['bar'] + ] + ], + // [ + // 'delete' => [ + // 'table' => $tableName, + // 'keySet' => [ + // 'keys' => ['foo','bar'], + // 'ranges' => [ + // [ + // 'startOpen' => ['foo'], + // 'endClosed' => ['bar'] + // ] + // ] + // ] + // ] + // ] + ]; + + $insertMutationsArr = []; + $insert = $insertMutations[0]['insert']; + $insert['values'] = $this->formatListForApi($insertMutations[0]['insert']['values']); + $operation = (new Mutation\Write) + ->deserialize($insert, $codec); + + $mutation = new Mutation; + $mutation->setInsert($operation); + $insertMutationsArr[] = $mutation; + + // $delete = $mutations[1]['delete']; + // $delete['keySet']['keys'] = $this->formatListForApi($delete['keySet']['keys']); + // $operation = (new Mutation\Delete) + // ->deserialize($delete, $codec); + // $mutation = new Mutation; + // $mutation->setDelete($operation); + // $mutationsArr[] = $mutation; + + return [ + [ + 'listInstanceConfigs', + ['projectId' => self::PROJECT], + [self::PROJECT, []] + ], + [ + 'getInstanceConfig', + ['name' => $configName], + [$configName, []] + ], + [ + 'listInstances', + ['projectId' => self::PROJECT], + [self::PROJECT, []] + ], + [ + 'getInstance', + ['name' => $instanceName], + [$instanceName, []] + ], + [ + 'createInstance', + ['projectId' => self::PROJECT, 'instanceId' => $instanceName] + $instanceArgs, + [self::PROJECT, $instanceName, $instance, []], + $lro, + null + ], + [ + 'updateInstance', + $instanceArgs, + [$instance, $fieldMask, []], + $lro, + null + ], + [ + 'deleteInstance', + ['name' => $instanceName], + [$instanceName, []] + ], + [ + 'setInstanceIamPolicy', + ['resource' => $instanceName, 'policy' => $policy], + [$instanceName, $policy, []] + ], + [ + 'getInstanceIamPolicy', + ['resource' => $instanceName], + [$instanceName, []] + ], + [ + 'testInstanceIamPermissions', + ['resource' => $instanceName, 'permissions' => $permissions], + [$instanceName, $permissions, []] + ], + [ + 'listDatabases', + ['instance' => $instanceName], + [$instanceName, []] + ], + [ + 'createDatabase', + ['instance' => $instanceName, 'createStatement' => $createStmt, 'extraStatements' => []], + [$instanceName, $createStmt, [], []], + $lro, + null + ], + [ + 'updateDatabaseDdl', + ['name' => $databaseName, 'statements' => []], + [$databaseName, [], []], + $lro, + null + ], + [ + 'dropDatabase', + ['name' => $databaseName], + [$databaseName, []] + ], + [ + 'getDatabaseDDL', + ['name' => $databaseName], + [$databaseName, []] + ], + [ + 'setDatabaseIamPolicy', + ['resource' => $databaseName, 'policy' => $policy], + [$databaseName, $policy, []] + ], + [ + 'getDatabaseIamPolicy', + ['resource' => $databaseName], + [$databaseName, []] + ], + [ + 'testDatabaseIamPermissions', + ['resource' => $databaseName, 'permissions' => $permissions], + [$databaseName, $permissions, []] + ], + [ + 'createSession', + ['database' => $databaseName], + [$databaseName, []] + ], + [ + 'getSession', + ['name' => $sessionName], + [$sessionName, []] + ], + [ + 'deleteSession', + ['name' => $sessionName], + [$sessionName, []] + ], + [ + 'executeStreamingSql', + [ + 'session' => $sessionName, + 'sql' => $sql, + 'transactionId' => $transactionName + ] + $mapped, + [$sessionName, $sql, [ + 'transaction' => $transactionSelector, + 'params' => $expectedParams, + 'paramTypes' => $expectedParamTypes + ]] + ], + [ + 'streamingRead', + ['keySet' => [], 'transactionId' => $transactionName, 'session' => $sessionName, 'table' => $tableName, 'columns' => $columns], + [$sessionName, $tableName, $columns, $keySet, ['transaction' => $transactionSelector]] + ], + // test read write + [ + 'beginTransaction', + ['session' => $sessionName, 'transactionOptions' => $readWriteTransactionArgs], + [$sessionName, $readWriteTransactionOptions, []] + ], + // test read only + [ + 'beginTransaction', + ['session' => $sessionName, 'transactionOptions' => $readOnlyTransactionArgs], + [$sessionName, $readOnlyTransactionOptions, []] + ], + // test insert + [ + 'commit', + ['session' => $sessionName, 'mutations' => $insertMutations], + [$sessionName, $insertMutationsArr, []] + ], + // test single-use transaction + [ + 'commit', + ['session' => $sessionName, 'mutations' => [], 'singleUseTransaction' => true], + [$sessionName, [], ['singleUseTransaction' => $readWriteTransactionOptions]] + ], + [ + 'rollback', + ['session' => $sessionName, 'transactionId' => $transactionName], + [$sessionName, $transactionName, []] + ], + // ['getOperation'], + // ['cancelOperation'], + // ['deleteOperation'], + // ['listOperations'] + ]; + } +} diff --git a/tests/unit/Spanner/DatabaseTest.php b/tests/unit/Spanner/DatabaseTest.php index 0dffb84bc936..f6dc5692973c 100644 --- a/tests/unit/Spanner/DatabaseTest.php +++ b/tests/unit/Spanner/DatabaseTest.php @@ -15,7 +15,7 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Core\Exception\AbortedException; use Google\Cloud\Core\Exception\NotFoundException; @@ -23,6 +23,7 @@ use Google\Cloud\Core\LongRunning\LongRunningConnectionInterface; use Google\Cloud\Core\LongRunning\LongRunningOperation; use Google\Cloud\Spanner\Admin\Database\V1\DatabaseAdminClient; +use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Database; use Google\Cloud\Spanner\Duration; @@ -77,7 +78,7 @@ public function setUp() $this->sessionPool->release(Argument::type(Session::class)) ->willReturn(null); - $this->instance->name()->willReturn(self::INSTANCE); + $this->instance->name()->willReturn(InstanceAdminClient::formatInstanceName(self::PROJECT, self::INSTANCE)); $args = [ $this->connection->reveal(), @@ -96,12 +97,55 @@ public function setUp() $this->database = \Google\Cloud\Dev\stub(Database::class, $args, $props); } + public function testName() + { + $this->assertEquals($this->database->name(), DatabaseAdminClient::formatDatabaseName(self::PROJECT, self::INSTANCE, self::DATABASE)); + } + + public function testInfo() + { + $res = [ + 'name' => $this->database->name() + ]; + + $this->connection->getDatabase(Argument::any()) + ->shouldBeCalledTimes(1) + ->willReturn($res); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $this->assertEquals($res, $this->database->info()); + + // Make sure the request only is sent once. + $this->database->info(); + } + + public function testReload() + { + $res = [ + 'name' => $this->database->name() + ]; + + $this->connection->getDatabase(Argument::any()) + ->shouldBeCalledTimes(2) + ->willReturn($res); + + $this->database->___setProperty('connection', $this->connection->reveal()); + + $this->assertEquals($res, $this->database->reload()); + + // Make sure the request is sent each time the method is called. + $this->database->reload(); + } + /** * @group spanneradmin */ public function testExists() { - $this->connection->getDatabaseDDL(Argument::any()) + $this->connection->getDatabase(Argument::withEntry( + 'name', DatabaseAdminClient::formatDatabaseName(self::PROJECT, self::INSTANCE, self::DATABASE) + )) ->shouldBeCalled() ->willReturn([]); @@ -115,7 +159,7 @@ public function testExists() */ public function testExistsNotFound() { - $this->connection->getDatabaseDDL(Argument::any()) + $this->connection->getDatabase(Argument::any()) ->shouldBeCalled() ->willThrow(new NotFoundException('', 404)); @@ -130,9 +174,11 @@ public function testExistsNotFound() public function testUpdateDdl() { $statement = 'foo'; - $this->connection->updateDatabase([ + $this->connection->updateDatabaseDdl([ 'name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT, self::INSTANCE, self::DATABASE), 'statements' => [$statement] + ])->willReturn([ + 'name' => 'my-operation' ]); $this->database->___setProperty('connection', $this->connection->reveal()); @@ -146,14 +192,16 @@ public function testUpdateDdl() public function testUpdateDdlBatch() { $statements = ['foo', 'bar']; - $this->connection->updateDatabase([ + $this->connection->updateDatabaseDdl([ 'name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT, self::INSTANCE, self::DATABASE), 'statements' => $statements + ])->willReturn([ + 'name' => 'my-operation' ]); $this->database->___setProperty('connection', $this->connection->reveal()); - $this->database->updateDdl($statements); + $this->database->updateDdlBatch($statements); } /** @@ -162,7 +210,7 @@ public function testUpdateDdlBatch() public function testUpdateWithSingleStatement() { $statement = 'foo'; - $this->connection->updateDatabase([ + $this->connection->updateDatabaseDdl([ 'name' => DatabaseAdminClient::formatDatabaseName(self::PROJECT, self::INSTANCE, self::DATABASE), 'statements' => ['foo'] ])->shouldBeCalled()->willReturn(['name' => 'operations/foo']); @@ -258,17 +306,40 @@ public function testRunTransaction() ->shouldBeCalled() ->willReturn(['id' => self::TRANSACTION]); + $this->connection->commit(Argument::any()) + ->shouldBeCalled() + ->willReturn(['commitTimestamp' => '2017-01-09T18:05:22.534799Z']); + $this->refreshOperation(); $hasTransaction = false; $this->database->runTransaction(function (Transaction $t) use (&$hasTransaction) { $hasTransaction = true; + + $t->commit(); }); $this->assertTrue($hasTransaction); } + /** + * @expectedException RuntimeException + */ + public function testRunTransactionNoCommit() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldBeCalled() + ->willReturn(['id' => self::TRANSACTION]); + + $this->connection->rollback(Argument::any()) + ->shouldBeCalled(); + + $this->refreshOperation(); + + $this->database->runTransaction(function (Transaction $t) {}); + } + public function testRunTransactionRetry() { $abort = new AbortedException('foo', 409, null, [ @@ -309,8 +380,8 @@ public function testRunTransactionAborted() $abort = new AbortedException('foo', 409, null, [ [ 'retryDelay' => [ - 'seconds' => 1, - 'nanos' => 0 + 'seconds' => 0, + 'nanos' => 500 ] ] ]); diff --git a/tests/unit/Spanner/DateTest.php b/tests/unit/Spanner/DateTest.php index 1c25c6bc3245..9ccb45b8d717 100644 --- a/tests/unit/Spanner/DateTest.php +++ b/tests/unit/Spanner/DateTest.php @@ -15,7 +15,7 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Spanner\Date; @@ -33,6 +33,12 @@ public function setUp() $this->date = new Date($this->dt); } + public function testCreateFromValues() + { + $date = Date::createFromValues(1989,10,11); + $this->assertEquals($date->formatAsString(), $this->date->formatAsString()); + } + public function testGet() { $this->assertEquals($this->dt, $this->date->get()); diff --git a/tests/unit/Spanner/DurationTest.php b/tests/unit/Spanner/DurationTest.php index 37d2575e42f7..46c40d931260 100644 --- a/tests/unit/Spanner/DurationTest.php +++ b/tests/unit/Spanner/DurationTest.php @@ -15,7 +15,7 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Spanner\Duration; diff --git a/tests/unit/Spanner/ConfigurationTest.php b/tests/unit/Spanner/InstanceConfigurationTest.php similarity index 79% rename from tests/unit/Spanner/ConfigurationTest.php rename to tests/unit/Spanner/InstanceConfigurationTest.php index 9cdceb8c336d..f8515a4a0d6c 100644 --- a/tests/unit/Spanner/ConfigurationTest.php +++ b/tests/unit/Spanner/InstanceConfigurationTest.php @@ -19,7 +19,7 @@ use Google\Cloud\Core\Exception\NotFoundException; use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; -use Google\Cloud\Spanner\Configuration; +use Google\Cloud\Spanner\InstanceConfiguration; use Google\Cloud\Spanner\Connection\ConnectionInterface; use Prophecy\Argument; @@ -27,7 +27,7 @@ * @group spanneradmin * @group spanner */ -class ConfigurationTest extends \PHPUnit_Framework_TestCase +class InstanceConfigurationTest extends \PHPUnit_Framework_TestCase { const PROJECT_ID = 'test-project'; const NAME = 'test-config'; @@ -38,7 +38,7 @@ class ConfigurationTest extends \PHPUnit_Framework_TestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->configuration = \Google\Cloud\Dev\stub(Configuration::class, [ + $this->configuration = \Google\Cloud\Dev\stub(InstanceConfiguration::class, [ $this->connection->reveal(), self::PROJECT_ID, self::NAME @@ -47,16 +47,16 @@ public function setUp() public function testName() { - $this->assertEquals(self::NAME, $this->configuration->name()); + $this->assertEquals(self::NAME, InstanceAdminClient::parseInstanceConfigFromInstanceConfigName($this->configuration->name())); } public function testInfo() { - $this->connection->getConfig(Argument::any())->shouldNotBeCalled(); + $this->connection->getInstanceConfig(Argument::any())->shouldNotBeCalled(); $this->configuration->___setProperty('connection', $this->connection->reveal()); $info = ['foo' => 'bar']; - $config = \Google\Cloud\Dev\stub(Configuration::class, [ + $config = \Google\Cloud\Dev\stub(InstanceConfiguration::class, [ $this->connection->reveal(), self::PROJECT_ID, self::NAME, @@ -70,7 +70,7 @@ public function testInfoWithReload() { $info = ['foo' => 'bar']; - $this->connection->getConfig([ + $this->connection->getInstanceConfig([ 'name' => InstanceAdminClient::formatInstanceConfigName(self::PROJECT_ID, self::NAME), 'projectId' => self::PROJECT_ID ])->shouldBeCalled()->willReturn($info); @@ -82,7 +82,7 @@ public function testInfoWithReload() public function testExists() { - $this->connection->getConfig(Argument::any())->willReturn([]); + $this->connection->getInstanceConfig(Argument::any())->willReturn([]); $this->configuration->___setProperty('connection', $this->connection->reveal()); $this->assertTrue($this->configuration->exists()); @@ -90,7 +90,7 @@ public function testExists() public function testExistsDoesntExist() { - $this->connection->getConfig(Argument::any())->willThrow(new NotFoundException('', 404)); + $this->connection->getInstanceConfig(Argument::any())->willThrow(new NotFoundException('', 404)); $this->configuration->___setProperty('connection', $this->connection->reveal()); $this->assertFalse($this->configuration->exists()); @@ -100,7 +100,7 @@ public function testReload() { $info = ['foo' => 'bar']; - $this->connection->getConfig([ + $this->connection->getInstanceConfig([ 'name' => InstanceAdminClient::formatInstanceConfigName(self::PROJECT_ID, self::NAME), 'projectId' => self::PROJECT_ID ])->shouldBeCalledTimes(1)->willReturn($info); diff --git a/tests/unit/Spanner/InstanceTest.php b/tests/unit/Spanner/InstanceTest.php index 90533742c67d..7542546fafa7 100644 --- a/tests/unit/Spanner/InstanceTest.php +++ b/tests/unit/Spanner/InstanceTest.php @@ -60,7 +60,7 @@ public function setUp() public function testName() { - $this->assertEquals(self::NAME, $this->instance->name()); + $this->assertEquals(self::NAME, InstanceAdminClient::parseInstanceFromInstanceName($this->instance->name())); } public function testInfo() @@ -150,20 +150,16 @@ public function testUpdate() { $instance = $this->getDefaultInstance(); - $this->connection->getInstance(Argument::any()) - ->shouldBeCalledTimes(1) - ->willReturn($instance); - $this->connection->updateInstance([ + 'displayName' => 'bar', 'name' => $instance['name'], - 'displayName' => $instance['displayName'], - 'nodeCount' => $instance['nodeCount'], - 'labels' => [], - ])->shouldBeCalled(); + ])->shouldBeCalled()->willReturn([ + 'name' => 'my-operation' + ]); $this->instance->___setProperty('connection', $this->connection->reveal()); - $this->instance->update(); + $this->instance->update(['displayName' => 'bar']); } public function testUpdateWithExistingLabels() @@ -171,20 +167,16 @@ public function testUpdateWithExistingLabels() $instance = $this->getDefaultInstance(); $instance['labels'] = ['foo' => 'bar']; - $this->connection->getInstance(Argument::any()) - ->shouldBeCalledTimes(1) - ->willReturn($instance); - $this->connection->updateInstance([ - 'name' => $instance['name'], - 'displayName' => $instance['displayName'], - 'nodeCount' => $instance['nodeCount'], 'labels' => $instance['labels'], - ])->shouldBeCalled(); + 'name' => $instance['name'], + ])->shouldBeCalled()->willReturn([ + 'name' => 'my-operation' + ]); $this->instance->___setProperty('connection', $this->connection->reveal()); - $this->instance->update(); + $this->instance->update(['labels' => $instance['labels']]); } public function testUpdateWithChanges() @@ -199,16 +191,14 @@ public function testUpdateWithChanges() 'displayName' => 'New Name', ]; - $this->connection->getInstance(Argument::any()) - ->shouldBeCalledTimes(1) - ->willReturn($instance); - $this->connection->updateInstance([ 'name' => $instance['name'], 'displayName' => $changes['displayName'], 'nodeCount' => $changes['nodeCount'], 'labels' => $changes['labels'], - ])->shouldBeCalled(); + ])->shouldBeCalled()->willReturn([ + 'name' => 'my-operation' + ]); $this->instance->___setProperty('connection', $this->connection->reveal()); @@ -251,7 +241,7 @@ public function testDatabase() { $database = $this->instance->database('test-database'); $this->assertInstanceOf(Database::class, $database); - $this->assertEquals('test-database', $database->name()); + $this->assertEquals('test-database', DatabaseAdminClient::parseDatabaseFromDatabaseName($database->name())); } public function testDatabases() @@ -274,8 +264,8 @@ public function testDatabases() $dbs = iterator_to_array($dbs); $this->assertEquals(2, count($dbs)); - $this->assertEquals('database1', $dbs[0]->name()); - $this->assertEquals('database2', $dbs[1]->name()); + $this->assertEquals('database1', DatabaseAdminClient::parseDatabaseFromDatabaseName($dbs[0]->name())); + $this->assertEquals('database2', DatabaseAdminClient::parseDatabaseFromDatabaseName($dbs[1]->name())); } public function testDatabasesPaged() @@ -299,8 +289,8 @@ public function testDatabasesPaged() $dbs = iterator_to_array($dbs); $this->assertEquals(2, count($dbs)); - $this->assertEquals('database1', $dbs[0]->name()); - $this->assertEquals('database2', $dbs[1]->name()); + $this->assertEquals('database1', DatabaseAdminClient::parseDatabaseFromDatabaseName($dbs[0]->name())); + $this->assertEquals('database2', DatabaseAdminClient::parseDatabaseFromDatabaseName($dbs[1]->name())); } public function testIam() diff --git a/tests/unit/Spanner/KeyRangeTest.php b/tests/unit/Spanner/KeyRangeTest.php index f3903ce6aa7d..8d378ab3df4b 100644 --- a/tests/unit/Spanner/KeyRangeTest.php +++ b/tests/unit/Spanner/KeyRangeTest.php @@ -15,7 +15,7 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Spanner\KeyRange; @@ -31,6 +31,22 @@ public function setUp() $this->range = new KeyRange; } + public function testPrefixMatch() + { + $key = ['foo']; + + $range = new KeyRange([ + 'start' => $key, + 'end' => $key, + 'startType' => KeyRange::TYPE_CLOSED, + 'endType' => KeyRange::TYPE_CLOSED, + ]); + + $prefixRange = KeyRange::prefixMatch($key); + + $this->assertEquals($range, $prefixRange); + } + public function testGetters() { $range = new KeyRange([ diff --git a/tests/unit/Spanner/KeySetTest.php b/tests/unit/Spanner/KeySetTest.php index 75551a6dc946..4c13caddba0c 100644 --- a/tests/unit/Spanner/KeySetTest.php +++ b/tests/unit/Spanner/KeySetTest.php @@ -15,7 +15,7 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Spanner\KeyRange; use Google\Cloud\Spanner\KeySet; @@ -87,7 +87,7 @@ public function testSetMatchAll() $this->assertTrue($set->keySetObject()['all']); $set->setMatchAll(false); - $this->assertFalse($set->keySetObject()['all']); + $this->assertFalse(isset($set->keySetObject()['all'])); } public function testRanges() @@ -116,4 +116,20 @@ public function testMatchAll() $set->setMatchAll(true); $this->assertTrue($set->matchAll()); } + + /** + * @expectedException InvalidArgumentException + */ + public function testInvalidKeys() + { + new KeySet(['keys' => 'foo']); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testInvalidAll() + { + new KeySet(['all' => 1]); + } } diff --git a/tests/unit/Spanner/OperationTest.php b/tests/unit/Spanner/OperationTest.php index e111d718959f..af4f1e8b1da6 100644 --- a/tests/unit/Spanner/OperationTest.php +++ b/tests/unit/Spanner/OperationTest.php @@ -15,7 +15,7 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\KeyRange; @@ -269,9 +269,23 @@ public function testSnapshot() $snap = $this->operation->snapshot($this->session); $this->assertInstanceOf(Snapshot::class, $snap); + $this->assertEquals(Snapshot::TYPE_PRE_ALLOCATED, $snap->type()); $this->assertEquals(self::TRANSACTION, $snap->id()); } + public function testSnapshotSingleUse() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldNotBeCalled(); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $snap = $this->operation->snapshot($this->session, ['singleUse' => true]); + $this->assertInstanceOf(Snapshot::class, $snap); + $this->assertEquals(Snapshot::TYPE_SINGLE_USE, $snap->type()); + $this->assertNull($snap->id()); + } + public function testSnapshotWithTimestamp() { $this->connection->beginTransaction(Argument::any()) diff --git a/tests/unit/Spanner/ResultTest.php b/tests/unit/Spanner/ResultTest.php index f61fe1d08cc0..173468c00ada 100644 --- a/tests/unit/Spanner/ResultTest.php +++ b/tests/unit/Spanner/ResultTest.php @@ -15,21 +15,18 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; -use Google\Cloud\Spanner\Operation; -use Google\Cloud\Spanner\Result; -use Google\Cloud\Spanner\Session\Session; use Google\Cloud\Spanner\Snapshot; use Google\Cloud\Spanner\Transaction; -use Google\Cloud\Spanner\ValueMapper; -use Prophecy\Argument; /** * @group spanner */ class ResultTest extends \PHPUnit_Framework_TestCase { + use ResultTestTrait; + /** * @dataProvider streamingDataProvider */ @@ -40,13 +37,6 @@ public function testRows($chunks, $expectedValues) $this->assertEquals($expectedValues, $result); } - public function streamingDataProvider() - { - foreach ($this->getStreamingDataFixture()['tests'] as $test) { - yield [$test['chunks'], $test['result']['value']]; - } - } - public function testIterator() { $fixture = $this->getStreamingDataFixture()['tests'][0]; @@ -96,56 +86,4 @@ public function testSnapshot() $result->rows()->next(); $this->assertInstanceOf(Snapshot::class, $result->snapshot()); } - - private function getResultClass($chunks, $context = 'r') - { - $operation = $this->prophesize(Operation::class); - $session = $this->prophesize(Session::class)->reveal(); - $mapper = $this->prophesize(ValueMapper::class); - $transaction = $this->prophesize(Transaction::class); - $snapshot = $this->prophesize(Snapshot::class); - $mapper->decodeValues( - Argument::any(), - Argument::any() - )->will(function ($args) { - return $args[1]; - }); - - if ($context === 'r') { - $operation->createSnapshot( - $session, - Argument::type('array') - )->willReturn($snapshot->reveal()); - } else { - $operation->createTransaction( - $session, - Argument::type('array') - )->willReturn($transaction->reveal()); - } - - return new Result( - $operation->reveal(), - $session, - function () use ($chunks) { - return $this->resultGenerator($chunks); - }, - $context, - $mapper->reveal() - ); - } - - private function resultGenerator($chunks) - { - foreach ($chunks as $chunk) { - yield json_decode($chunk, true); - } - } - - private function getStreamingDataFixture() - { - return json_decode( - file_get_contents(__DIR__ .'/../fixtures/spanner/streaming-read-acceptance-test.json'), - true - ); - } } diff --git a/tests/unit/Spanner/ResultTestTrait.php b/tests/unit/Spanner/ResultTestTrait.php new file mode 100644 index 000000000000..b40d1d270cdc --- /dev/null +++ b/tests/unit/Spanner/ResultTestTrait.php @@ -0,0 +1,96 @@ +getStreamingDataFixture()['tests'] as $test) { + yield [$test['chunks'], $test['result']['value']]; + } + } + + public function streamingDataProviderFirstChunk() + { + foreach ($this->getStreamingDataFixture()['tests'] as $test) { + yield [$test['chunks'], $test['result']['value']]; + break; + } + } + + private function getResultClass($chunks, $context = 'r') + { + $operation = $this->prophesize(Operation::class); + $session = $this->prophesize(Session::class)->reveal(); + $mapper = $this->prophesize(ValueMapper::class); + $transaction = $this->prophesize(Transaction::class); + $snapshot = $this->prophesize(Snapshot::class); + $mapper->decodeValues( + Argument::any(), + Argument::any() + )->will(function ($args) { + return $args[1]; + }); + + if ($context === 'r') { + $operation->createSnapshot( + $session, + Argument::type('array') + )->willReturn($snapshot->reveal()); + } else { + $operation->createTransaction( + $session, + Argument::type('array') + )->willReturn($transaction->reveal()); + } + + return new Result( + $operation->reveal(), + $session, + function () use ($chunks) { + return $this->resultGenerator($chunks); + }, + $context, + $mapper->reveal() + ); + } + + private function resultGenerator($chunks) + { + foreach ($chunks as $chunk) { + yield json_decode($chunk, true); + } + } + + private function getStreamingDataFixture() + { + return json_decode( + file_get_contents(__DIR__ .'/../fixtures/spanner/streaming-read-acceptance-test.json'), + true + ); + } +} diff --git a/tests/unit/Spanner/Session/CacheSessionPoolTest.php b/tests/unit/Spanner/Session/CacheSessionPoolTest.php index 47a494536233..8391af10b88e 100644 --- a/tests/unit/Spanner/Session/CacheSessionPoolTest.php +++ b/tests/unit/Spanner/Session/CacheSessionPoolTest.php @@ -15,7 +15,7 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner\Session; +namespace Google\Cloud\Tests\Unit\Spanner\Session; use Google\Auth\Cache\MemoryCacheItemPool; use Google\Cloud\Spanner\Database; diff --git a/tests/unit/Spanner/SnapshotTest.php b/tests/unit/Spanner/SnapshotTest.php index cf54179ce9ed..e846882c5f9a 100644 --- a/tests/unit/Spanner/SnapshotTest.php +++ b/tests/unit/Spanner/SnapshotTest.php @@ -15,12 +15,13 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Spanner\Operation; use Google\Cloud\Spanner\Session\Session; use Google\Cloud\Spanner\Snapshot; use Google\Cloud\Spanner\Timestamp; +use Prophecy\Argument; /** * @group spanner @@ -33,16 +34,70 @@ class SnapshotTest extends \PHPUnit_Framework_TestCase public function setUp() { $this->timestamp = new Timestamp(new \DateTime); + + $args = [ + 'id' => 'foo', + 'readTimestamp' => $this->timestamp + ]; + $this->snapshot = new Snapshot( $this->prophesize(Operation::class)->reveal(), $this->prophesize(Session::class)->reveal(), - 'foo', - $this->timestamp + $args + ); + } + + public function testTypeIsPreAllocated() + { + $this->assertEquals(Snapshot::TYPE_PRE_ALLOCATED, $this->snapshot->type()); + } + + public function testTypeIsSingleUse() + { + $snapshot = new Snapshot( + $this->prophesize(Operation::class)->reveal(), + $this->prophesize(Session::class)->reveal() ); + + $this->assertEquals(Snapshot::TYPE_SINGLE_USE, $snapshot->type()); } public function testReadTimestamp() { $this->assertEquals($this->timestamp, $this->snapshot->readTimestamp()); } + + /** + * @expectedException InvalidArgumentException + */ + public function testWithInvalidTimestamp() + { + $args = [ + 'readTimestamp' => 'foo' + ]; + + new Snapshot( + $this->prophesize(Operation::class)->reveal(), + $this->prophesize(Session::class)->reveal(), + $args + ); + } + + /** + * @expectedException BadMethodCallException + */ + public function testSingleUseFailsOnSecondUse() + { + $operation = $this->prophesize(Operation::class); + $operation->execute(Argument::any(), Argument::any(), Argument::any()) + ->shouldBeCalled(); + + $snapshot = new Snapshot( + $operation->reveal(), + $this->prophesize(Session::class)->reveal() + ); + + $snapshot->execute('foo'); + $snapshot->execute('foo'); + } } diff --git a/tests/unit/Spanner/SpannerClientTest.php b/tests/unit/Spanner/SpannerClientTest.php index e7006cbe2a68..c3c9937bff9a 100644 --- a/tests/unit/Spanner/SpannerClientTest.php +++ b/tests/unit/Spanner/SpannerClientTest.php @@ -15,18 +15,20 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Core\Int64; use Google\Cloud\Core\Iterator\ItemIterator; use Google\Cloud\Core\LongRunning\LongRunningOperation; +use Google\Cloud\Spanner\Admin\Database\V1\DatabaseAdminClient; +use Google\Cloud\Spanner\Admin\Instance\V1\InstanceAdminClient; use Google\Cloud\Spanner\Bytes; -use Google\Cloud\Spanner\Configuration; use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Database; use Google\Cloud\Spanner\Date; use Google\Cloud\Spanner\Duration; use Google\Cloud\Spanner\Instance; +use Google\Cloud\Spanner\InstanceConfiguration; use Google\Cloud\Spanner\KeyRange; use Google\Cloud\Spanner\KeySet; use Google\Cloud\Spanner\SpannerClient; @@ -60,17 +62,17 @@ public function setUp() /** * @group spanneradmin */ - public function testConfigurations() + public function testInstanceConfigurations() { - $this->connection->listConfigs(Argument::any()) + $this->connection->listInstanceConfigs(Argument::any()) ->shouldBeCalled() ->willReturn([ 'instanceConfigs' => [ [ - 'name' => 'projects/'. self::PROJECT .'/instanceConfigs/'. self::CONFIG, + 'name' => InstanceAdminClient::formatInstanceConfigName(self::PROJECT, self::CONFIG), 'displayName' => 'Bar' ], [ - 'name' => 'projects/'. self::PROJECT .'/instanceConfigs/'. self::CONFIG, + 'name' => InstanceAdminClient::formatInstanceConfigName(self::PROJECT, self::CONFIG), 'displayName' => 'Bat' ] ] @@ -78,20 +80,20 @@ public function testConfigurations() $this->client->___setProperty('connection', $this->connection->reveal()); - $configs = $this->client->configurations(); + $configs = $this->client->instanceConfigurations(); $this->assertInstanceOf(ItemIterator::class, $configs); $configs = iterator_to_array($configs); $this->assertEquals(2, count($configs)); - $this->assertInstanceOf(Configuration::class, $configs[0]); - $this->assertInstanceOf(Configuration::class, $configs[1]); + $this->assertInstanceOf(InstanceConfiguration::class, $configs[0]); + $this->assertInstanceOf(InstanceConfiguration::class, $configs[1]); } /** * @group spanneradmin */ - public function testPagedConfigurations() + public function testPagedInstanceConfigurations() { $firstCall = [ 'instanceConfigs' => [ @@ -112,31 +114,31 @@ public function testPagedConfigurations() ] ]; - $this->connection->listConfigs(Argument::any()) + $this->connection->listInstanceConfigs(Argument::any()) ->shouldBeCalledTimes(2) ->willReturn($firstCall, $secondCall); $this->client->___setProperty('connection', $this->connection->reveal()); - $configs = $this->client->configurations(); + $configs = $this->client->instanceConfigurations(); $this->assertInstanceOf(ItemIterator::class, $configs); $configs = iterator_to_array($configs); $this->assertEquals(2, count($configs)); - $this->assertInstanceOf(Configuration::class, $configs[0]); - $this->assertInstanceOf(Configuration::class, $configs[1]); + $this->assertInstanceOf(InstanceConfiguration::class, $configs[0]); + $this->assertInstanceOf(InstanceConfiguration::class, $configs[1]); } /** * @group spanneradmin */ - public function testConfiguration() + public function testInstanceConfiguration() { - $config = $this->client->configuration('bar'); + $config = $this->client->instanceConfiguration('bar'); - $this->assertInstanceOf(Configuration::class, $config); - $this->assertEquals('bar', $config->name()); + $this->assertInstanceOf(InstanceConfiguration::class, $config); + $this->assertEquals('bar', InstanceAdminClient::parseInstanceConfigFromInstanceConfigName($config->name())); } /** @@ -145,8 +147,8 @@ public function testConfiguration() public function testCreateInstance() { $this->connection->createInstance(Argument::that(function ($arg) { - if ($arg['name'] !== 'projects/'. self::PROJECT .'/instances/'. self::INSTANCE) return false; - if ($arg['config'] !== 'projects/'. self::PROJECT .'/instanceConfigs/'. self::CONFIG) return false; + if ($arg['name'] !== InstanceAdminClient::formatInstanceName(self::PROJECT, self::INSTANCE)) return false; + if ($arg['config'] !== InstanceAdminClient::formatInstanceConfigName(self::PROJECT, self::CONFIG)) return false; return true; })) @@ -157,8 +159,8 @@ public function testCreateInstance() $this->client->___setProperty('connection', $this->connection->reveal()); - $config = $this->prophesize(Configuration::class); - $config->name()->willReturn(self::CONFIG); + $config = $this->prophesize(InstanceConfiguration::class); + $config->name()->willReturn(InstanceAdminClient::formatInstanceConfigName(self::PROJECT, self::CONFIG)); $operation = $this->client->createInstance($config->reveal(), self::INSTANCE); @@ -172,7 +174,7 @@ public function testInstance() { $i = $this->client->instance('foo'); $this->assertInstanceOf(Instance::class, $i); - $this->assertEquals('foo', $i->name()); + $this->assertEquals('foo', InstanceAdminClient::parseInstanceFromInstanceName($i->name())); } /** @@ -205,8 +207,8 @@ public function testInstances() $instances = iterator_to_array($instances); $this->assertEquals(2, count($instances)); - $this->assertEquals('foo', $instances[0]->name()); - $this->assertEquals('bar', $instances[1]->name()); + $this->assertEquals('foo', InstanceAdminClient::parseInstanceFromInstanceName($instances[0]->name())); + $this->assertEquals('bar', InstanceAdminClient::parseInstanceFromInstanceName($instances[1]->name())); } /** @@ -225,7 +227,7 @@ public function testConnect() { $database = $this->client->connect(self::INSTANCE, self::DATABASE); $this->assertInstanceOf(Database::class, $database); - $this->assertEquals(self::DATABASE, $database->name()); + $this->assertEquals(self::DATABASE, DatabaseAdminClient::parseDatabaseFromDatabaseName($database->name())); } public function testConnectWithInstance() @@ -233,7 +235,7 @@ public function testConnectWithInstance() $inst = $this->client->instance(self::INSTANCE); $database = $this->client->connect($inst, self::DATABASE); $this->assertInstanceOf(Database::class, $database); - $this->assertEquals(self::DATABASE, $database->name()); + $this->assertEquals(self::DATABASE, DatabaseAdminClient::parseDatabaseFromDatabaseName($database->name())); } public function testKeyset() diff --git a/tests/unit/Spanner/TimestampTest.php b/tests/unit/Spanner/TimestampTest.php index c18ee94e95c2..62d35c94d8f7 100644 --- a/tests/unit/Spanner/TimestampTest.php +++ b/tests/unit/Spanner/TimestampTest.php @@ -15,7 +15,7 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Spanner\Timestamp; diff --git a/tests/unit/Spanner/TransactionConfigurationTraitTest.php b/tests/unit/Spanner/TransactionConfigurationTraitTest.php index 2dd3f408e0b3..da1119bb9060 100644 --- a/tests/unit/Spanner/TransactionConfigurationTraitTest.php +++ b/tests/unit/Spanner/TransactionConfigurationTraitTest.php @@ -15,7 +15,7 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Spanner\Duration; use Google\Cloud\Spanner\Session\SessionPoolInterface; @@ -49,7 +49,7 @@ public function testTransactionSelectorBasicSnapshot() $args = []; $res = $this->impl->proxyTransactionSelector($args); $this->assertEquals(SessionPoolInterface::CONTEXT_READ, $res[1]); - $this->assertTrue($res[0]['singleUse']['readOnly']['strong']); + $this->assertEquals($res[0]['singleUse']['readOnly'], ['strong' => true]); } public function testTransactionSelectorExistingId() @@ -68,12 +68,19 @@ public function testTransactionSelectorReadWrite() $this->assertEquals($this->impl->proxyConfigureTransactionOptions(), $res[0]['singleUse']); } + public function testTransactionSelectorReadOnly() + { + $args = ['transactionType' => SessionPoolInterface::CONTEXT_READ]; + $res = $this->impl->proxyTransactionSelector($args); + $this->assertEquals(SessionPoolInterface::CONTEXT_READ, $res[1]); + } + public function testBegin() { $args = ['begin' => true]; $res = $this->impl->proxyTransactionSelector($args); $this->assertEquals(SessionPoolInterface::CONTEXT_READ, $res[1]); - $this->assertTrue($res[0]['begin']['readOnly']['strong']); + $this->assertEquals($res[0]['begin']['readOnly'], ['strong' => true]); } public function testConfigureSnapshotOptionsReturnReadTimestamp() @@ -92,7 +99,7 @@ public function testConfigureSnapshotOptionsStrong() public function testConfigureSnapshotOptionsMinReadTimestamp() { - $args = ['minReadTimestamp' => $this->ts]; + $args = ['minReadTimestamp' => $this->ts, 'singleUse' => true]; $res = $this->impl->proxyConfigureSnapshotOptions($args); $this->assertEquals(self::TIMESTAMP, $res['readOnly']['minReadTimestamp']); } @@ -106,7 +113,7 @@ public function testConfigureSnapshotOptionsReadTimestamp() public function testConfigureSnapshotOptionsMaxStaleness() { - $args = ['maxStaleness' => $this->duration]; + $args = ['maxStaleness' => $this->duration, 'singleUse' => true]; $res = $this->impl->proxyConfigureSnapshotOptions($args); $this->assertEquals($this->dur, $res['readOnly']['maxStaleness']); } diff --git a/tests/unit/Spanner/TransactionTest.php b/tests/unit/Spanner/TransactionTest.php index db5e71d9ad0d..cafceb6fc8fa 100644 --- a/tests/unit/Spanner/TransactionTest.php +++ b/tests/unit/Spanner/TransactionTest.php @@ -15,7 +15,7 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Instance; @@ -47,6 +47,9 @@ class TransactionTest extends \PHPUnit_Framework_TestCase private $session; private $database; + private $transaction; + private $singleUseTransaction; + public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); @@ -71,6 +74,9 @@ public function setUp() ]; $this->transaction = \Google\Cloud\Dev\stub(Transaction::class, $args, $props); + + unset($args[2]); + $this->singleUseTransaction = \Google\Cloud\Dev\stub(Transaction::class, $args, $props); } public function testInsert() @@ -168,7 +174,7 @@ public function testDelete() $mutations = $this->transaction->___getProperty('mutations'); $this->assertEquals('Posts', $mutations[0]['delete']['table']); $this->assertEquals('foo', $mutations[0]['delete']['keySet']['keys'][0]); - $this->assertFalse($mutations[0]['delete']['keySet']['all']); + $this->assertFalse(isset($mutations[0]['delete']['keySet']['all'])); } public function testExecute() @@ -228,7 +234,7 @@ public function testCommit() } /** - * @expectedException RuntimeException + * @expectedException BadMethodCallException */ public function testCommitInvalidState() { @@ -247,7 +253,7 @@ public function testRollback() } /** - * @expectedException RuntimeException + * @expectedException BadMethodCallException */ public function testRollbackInvalidState() { @@ -268,6 +274,14 @@ public function testState() $this->assertEquals(Transaction::STATE_COMMITTED, $this->transaction->state()); } + /** + * @expectedException BadMethodCallException + */ + public function testInvalidReadContext() + { + $this->singleUseTransaction->execute('foo'); + } + // ******* // Helpers diff --git a/tests/unit/Spanner/TransactionTypeTest.php b/tests/unit/Spanner/TransactionTypeTest.php new file mode 100644 index 000000000000..491449f1739b --- /dev/null +++ b/tests/unit/Spanner/TransactionTypeTest.php @@ -0,0 +1,777 @@ +timestamp = (new \DateTimeImmutable)->format(Timestamp::FORMAT); + + $this->connection = $this->prophesize(ConnectionInterface::class); + + $this->connection->createSession(Argument::any()) + ->willReturn(['name' => SpannerClient::formatSessionName( + self::PROJECT, + self::INSTANCE, + self::DATABASE, + self::SESSION + )]); + } + + public function testDatabaseRunTransactionPreAllocate() + { + $this->connection->beginTransaction(Argument::that(function ($arg) { + if (!isset($arg['transactionOptions']['readWrite'])) return false; + if ($arg['singleUse']) return false; + + return true; + }))->shouldBeCalledTimes(1) + ->willReturn(['id' => self::TRANSACTION]); + + $this->connection->commit(Argument::withEntry('transactionId', self::TRANSACTION)) + ->shouldBeCalledTimes(1) + ->willReturn(['commitTimestamp' => $this->timestamp]); + + $database = $this->database($this->connection->reveal()); + + $database->runTransaction(function($t){ + $this->assertEquals($t->id(), self::TRANSACTION); + + $t->commit(); + }); + } + + public function testDatabaseRunTransactionSingleUse() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->commit(Argument::withEntry('singleUseTransaction', ['readWrite' => []])) + ->shouldBeCalledTimes(1) + ->willReturn(['commitTimestamp' => $this->timestamp]); + + $database = $this->database($this->connection->reveal()); + + $database->runTransaction(function($t){ + $this->assertNull($t->id()); + + $t->commit(); + }, ['singleUse' => true]); + } + + public function testDatabaseTransactionPreAllocate() + { + $this->connection->beginTransaction(Argument::that(function ($arg) { + if (!isset($arg['transactionOptions']['readWrite'])) return false; + if ($arg['singleUse']) return false; + + return true; + }))->shouldBeCalledTimes(1) + ->willReturn(['id' => self::TRANSACTION]); + + $database = $this->database($this->connection->reveal()); + + $transaction = $database->transaction(); + + $this->assertInstanceOf(Transaction::class, $transaction); + $this->assertEquals($transaction->id(), self::TRANSACTION); + } + + public function testDatabaseTransactionSingleUse() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $database = $this->database($this->connection->reveal()); + + $transaction = $database->transaction(['singleUse' => true]); + + $this->assertInstanceOf(Transaction::class, $transaction); + $this->assertNull($transaction->id()); + } + + public function testDatabaseSnapshotPreAllocate() + { + $this->connection->beginTransaction(Argument::that(function ($arg) { + if (!isset($arg['transactionOptions']['readOnly'])) return false; + if ($arg['singleUse']) return false; + + return true; + }))->shouldBeCalledTimes(1) + ->willReturn(['id' => self::TRANSACTION]); + + $database = $this->database($this->connection->reveal()); + + $snapshot = $database->snapshot(); + + $this->assertInstanceOf(Snapshot::class, $snapshot); + $this->assertEquals($snapshot->id(), self::TRANSACTION); + } + + public function testDatabaseSnapshotSingleUse() + { + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $database = $this->database($this->connection->reveal()); + + $snapshot = $database->snapshot(['singleUse' => true]); + + $this->assertInstanceOf(Snapshot::class, $snapshot); + $this->assertNull($snapshot->id()); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabaseSingleUseSnapshotMinReadTimestampAndMaxStaleness($chunks) + { + $seconds = 1; + $nanos = 2; + + $timestamp = new Timestamp(new \DateTimeImmutable($this->timestamp)); + $duration = new Duration($seconds, $nanos); + + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->executeStreamingSql(Argument::that(function ($arg) use ($seconds, $nanos) { + $opts = $arg['transaction']['singleUse']['readOnly']; + if (isset($opts['strong'])) return false; + if ($opts['minReadTimestamp'] !== $this->timestamp) return false; + if ($opts['maxStaleness']['seconds'] !== $seconds) return false; + if ($opts['maxStaleness']['nanos'] !== $nanos) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + + $snapshot = $database->snapshot([ + 'singleUse' => true, + 'minReadTimestamp' => $timestamp, + 'maxStaleness' => $duration + ]); + + $result = $snapshot->execute('SELECT * FROM Table')->rows()->current(); + } + + /** + * @expectedException BadMethodCallException + */ + public function testDatabasePreAllocatedSnapshotMinReadTimestamp() + { + $timestamp = new Timestamp(new \DateTimeImmutable($this->timestamp)); + + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->executeStreamingSql(Argument::any()) + ->shouldNotbeCalled(); + + $database = $this->database($this->connection->reveal()); + + $snapshot = $database->snapshot([ + 'minReadTimestamp' => $timestamp, + ]); + } + + /** + * @expectedException BadMethodCallException + */ + public function testDatabasePreAllocatedSnapshotMaxStaleness() + { + $seconds = 1; + $nanos = 2; + + $duration = new Duration($seconds, $nanos); + + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->executeStreamingSql(Argument::any()) + ->shouldNotbeCalled(); + + $database = $this->database($this->connection->reveal()); + + $snapshot = $database->snapshot([ + 'maxStaleness' => $duration + ]); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabaseSnapshotSingleUseReadTimestampAndExactStaleness($chunks) + { + $seconds = 1; + $nanos = 2; + + $timestamp = new Timestamp(new \DateTimeImmutable($this->timestamp)); + $duration = new Duration($seconds, $nanos); + + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->executeStreamingSql(Argument::that(function ($arg) use ($seconds, $nanos) { + $opts = $arg['transaction']['singleUse']['readOnly']; + if (isset($opts['strong'])) return false; + if ($opts['readTimestamp'] !== $this->timestamp) return false; + if ($opts['exactStaleness']['seconds'] !== $seconds) return false; + if ($opts['exactStaleness']['nanos'] !== $nanos) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + + $snapshot = $database->snapshot([ + 'singleUse' => true, + 'readTimestamp' => $timestamp, + 'exactStaleness' => $duration + ]); + + $snapshot->execute('SELECT * FROM Table')->rows()->current(); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabaseSnapshotPreAllocateReadTimestampAndExactStaleness($chunks) + { + $seconds = 1; + $nanos = 2; + + $timestamp = new Timestamp(new \DateTimeImmutable($this->timestamp)); + $duration = new Duration($seconds, $nanos); + + $this->connection->beginTransaction(Argument::that(function ($arg) use ($seconds, $nanos) { + if ($arg['singleUse']) return false; + + $opts = $arg['transactionOptions']['readOnly']; + if (isset($opts['strong'])) return false; + if ($opts['readTimestamp'] !== $this->timestamp) return false; + if ($opts['exactStaleness']['seconds'] !== $seconds) return false; + if ($opts['exactStaleness']['nanos'] !== $nanos) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn([ + 'id' => self::TRANSACTION + ]); + + $this->connection->executeStreamingSql(Argument::withEntry('transaction', ['id' => self::TRANSACTION])) + ->shouldBeCalledTimes(1) + ->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + + $snapshot = $database->snapshot([ + 'readTimestamp' => $timestamp, + 'exactStaleness' => $duration + ]); + + $snapshot->execute('SELECT * FROM Table')->rows()->current(); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabaseSingleUseSnapshotStrongConsistency($chunks) + { + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->executeStreamingSql(Argument::that(function ($arg) { + if (!$arg['transaction']['singleUse']['readOnly']['strong']) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + + $snapshot = $database->snapshot([ + 'singleUse' => true, + 'strong' => true + ]); + + $snapshot->execute('SELECT * FROM Table')->rows()->current(); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabasePreAllocatedSnapshotStrongConsistency($chunks) + { + $this->connection->beginTransaction(Argument::that(function ($arg) { + if ($arg['singleUse']) return false; + + if (!$arg['transactionOptions']['readOnly']['strong']) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn([ + 'id' => self::TRANSACTION + ]); + + $this->connection->executeStreamingSql(Argument::withEntry('transaction', ['id' => self::TRANSACTION])) + ->shouldBeCalledTimes(1) + ->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + + $snapshot = $database->snapshot([ + 'strong' => true + ]); + + $snapshot->execute('SELECT * FROM Table')->rows()->current(); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabaseSingleUseSnapshotDefaultsToStrongConsistency($chunks) + { + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->executeStreamingSql(Argument::that(function ($arg) { + if (!$arg['transaction']['singleUse']['readOnly']['strong']) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + + $snapshot = $database->snapshot([ + 'singleUse' => true, + ]); + + $snapshot->execute('SELECT * FROM Table')->rows()->current(); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabasePreAllocatedSnapshotDefaultsToStrongConsistency($chunks) + { + $this->connection->beginTransaction(Argument::that(function ($arg) { + if ($arg['singleUse']) return false; + + if (!$arg['transactionOptions']['readOnly']['strong']) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn([ + 'id' => self::TRANSACTION + ]); + + $this->connection->executeStreamingSql(Argument::withEntry('transaction', ['id' => self::TRANSACTION])) + ->shouldBeCalledTimes(1) + ->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + + $snapshot = $database->snapshot(); + + $snapshot->execute('SELECT * FROM Table')->rows()->current(); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabaseSnapshotReturnReadTimestamp($chunks) + { + $this->connection->beginTransaction(Argument::that(function ($arg) { + if ($arg['singleUse']) return false; + + if (!$arg['transactionOptions']['readOnly']['returnReadTimestamp']) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn([ + 'id' => self::TRANSACTION + ]); + + $this->connection->executeStreamingSql(Argument::withEntry('transaction', ['id' => self::TRANSACTION])) + ->shouldBeCalledTimes(1) + ->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + + $snapshot = $database->snapshot([ + 'returnReadTimestamp' => true + ]); + + $snapshot->execute('SELECT * FROM Table')->rows()->current(); + } + + public function testDatabaseInsertSingleUseReadWrite() + { + $this->connection->commit(Argument::that(function ($arg) { + if ($arg['singleUseTransaction']['readWrite'] !== []) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => $this->timestamp + ]); + + $database = $this->database($this->connection->reveal()); + + $database->insert('Table', [ + 'column' => 'value' + ]); + } + + public function testDatabaseInsertBatchSingleUseReadWrite() + { + $this->connection->commit(Argument::that(function ($arg) { + if ($arg['singleUseTransaction']['readWrite'] !== []) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => $this->timestamp + ]); + + $database = $this->database($this->connection->reveal()); + + $database->insertBatch('Table', [[ + 'column' => 'value' + ]]); + } + + public function testDatabaseUpdateSingleUseReadWrite() + { + $this->connection->commit(Argument::that(function ($arg) { + if ($arg['singleUseTransaction']['readWrite'] !== []) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => $this->timestamp + ]); + + $database = $this->database($this->connection->reveal()); + + $database->update('Table', [ + 'column' => 'value' + ]); + } + + public function testDatabaseUpdateBatchSingleUseReadWrite() + { + $this->connection->commit(Argument::that(function ($arg) { + if ($arg['singleUseTransaction']['readWrite'] !== []) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => $this->timestamp + ]); + + $database = $this->database($this->connection->reveal()); + + $database->updateBatch('Table', [[ + 'column' => 'value' + ]]); + } + + public function testDatabaseInsertOrUpdateSingleUseReadWrite() + { + $this->connection->commit(Argument::that(function ($arg) { + if ($arg['singleUseTransaction']['readWrite'] !== []) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => $this->timestamp + ]); + + $database = $this->database($this->connection->reveal()); + + $database->insertOrUpdate('Table', [ + 'column' => 'value' + ]); + } + + public function testDatabaseInsertOrUpdateBatchSingleUseReadWrite() + { + $this->connection->commit(Argument::that(function ($arg) { + if ($arg['singleUseTransaction']['readWrite'] !== []) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => $this->timestamp + ]); + + $database = $this->database($this->connection->reveal()); + + $database->insertOrUpdateBatch('Table', [[ + 'column' => 'value' + ]]); + } + + public function testDatabaseReplaceSingleUseReadWrite() + { + $this->connection->commit(Argument::that(function ($arg) { + if ($arg['singleUseTransaction']['readWrite'] !== []) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => $this->timestamp + ]); + + $database = $this->database($this->connection->reveal()); + + $database->replace('Table', [ + 'column' => 'value' + ]); + } + + public function testDatabaseReplaceBatchSingleUseReadWrite() + { + $this->connection->commit(Argument::that(function ($arg) { + if ($arg['singleUseTransaction']['readWrite'] !== []) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => $this->timestamp + ]); + + $database = $this->database($this->connection->reveal()); + + $database->replaceBatch('Table', [[ + 'column' => 'value' + ]]); + } + + public function testDatabaseDeleteSingleUseReadWrite() + { + $this->connection->commit(Argument::that(function ($arg) { + if ($arg['singleUseTransaction']['readWrite'] !== []) return false; + + return true; + }))->shouldBeCalled()->willReturn([ + 'commitTimestamp' => $this->timestamp + ]); + + $database = $this->database($this->connection->reveal()); + + $database->delete('Table', new KeySet); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabaseExecuteSingleUseReadOnly($chunks) + { + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->executeStreamingSql(Argument::that(function ($arg) { + if (!$arg['transaction']['singleUse']['readOnly']['strong']) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + $database->execute('SELECT * FROM Table')->rows()->current(); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabaseExecuteBeginReadOnly($chunks) + { + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->executeStreamingSql(Argument::that(function ($arg) { + if (!$arg['transaction']['begin']['readOnly']['strong']) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + $database->execute('SELECT * FROM Table', [ + 'begin' => true + ])->rows()->current(); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabaseExecuteBeginReadWrite($chunks) + { + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->executeStreamingSql(Argument::that(function ($arg) { + if ($arg['transaction']['begin']['readWrite'] !== []) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + $database->execute('SELECT * FROM Table', [ + 'begin' => true, + 'transactionType' => SessionPoolInterface::CONTEXT_READWRITE + ])->rows()->current(); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabaseReadSingleUseReadOnly($chunks) + { + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->streamingRead(Argument::that(function ($arg) { + if (!$arg['transaction']['singleUse']['readOnly']['strong']) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + $database->read('Table', new KeySet, [])->rows()->current(); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabaseReadBeginReadOnly($chunks) + { + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->streamingRead(Argument::that(function ($arg) { + if (!$arg['transaction']['begin']['readOnly']['strong']) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + $database->read('Table', new KeySet, [], [ + 'begin' => true + ])->rows()->current(); + } + + /** + * @dataProvider streamingDataProviderFirstChunk + */ + public function testDatabaseReadBeginReadWrite($chunks) + { + $this->connection->beginTransaction(Argument::any()) + ->shouldNotbeCalled(); + + $this->connection->streamingRead(Argument::that(function ($arg) { + if ($arg['transaction']['begin']['readWrite'] !== []) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn($this->resultGenerator($chunks)); + + $database = $this->database($this->connection->reveal()); + $database->read('Table', new KeySet, [], [ + 'begin' => true, + 'transactionType' => SessionPoolInterface::CONTEXT_READWRITE + ])->rows()->current(); + } + + public function testTransactionPreAllocatedRollback() + { + $this->connection->beginTransaction(Argument::that(function ($arg) { + if (!isset($arg['transactionOptions']['readWrite'])) return false; + + return true; + }))->shouldBeCalledTimes(1)->willReturn(['id' => self::TRANSACTION]); + + $this->connection->rollback(Argument::that(function ($arg) { + if ($arg['transactionId'] !== self::TRANSACTION) return false; + if ($arg['session'] !== SpannerClient::formatSessionName( + self::PROJECT, + self::INSTANCE, + self::DATABASE, + self::SESSION + )) return false; + + return true; + }))->shouldBeCalled(); + + $database = $this->database($this->connection->reveal()); + $t = $database->transaction(); + $t->rollback(); + } + + /** + * @expectedException BadMethodCallException + */ + public function testTransactionSingleUseRollback() + { + $this->connection->beginTransaction(Argument::any())->shouldNotbeCalled(); + $this->connection->rollback(Argument::any())->shouldNotbeCalled(); + + $database = $this->database($this->connection->reveal()); + $t = $database->transaction(['singleUse' => true]); + $t->rollback(); + } + + private function database(ConnectionInterface $connection) + { + $operation = new Operation($connection, false); + $instance = $this->prophesize(Instance::class); + $instance->name()->willReturn(InstanceAdminClient::formatInstanceName(self::PROJECT, self::INSTANCE)); + + $database = \Google\Cloud\Dev\stub(Database::class, [ + $connection, + $instance->reveal(), + $this->prophesize(LongRunningConnectionInterface::class)->reveal(), + [], + self::PROJECT, + self::DATABASE + ], ['operation']); + + $database->___setProperty('operation', $operation); + + return $database; + } +} diff --git a/tests/unit/Spanner/ValueMapperTest.php b/tests/unit/Spanner/ValueMapperTest.php index 404ba6a3201e..d5d83eabf9ea 100644 --- a/tests/unit/Spanner/ValueMapperTest.php +++ b/tests/unit/Spanner/ValueMapperTest.php @@ -15,7 +15,7 @@ * limitations under the License. */ -namespace Google\Cloud\Tests\Spanner; +namespace Google\Cloud\Tests\Unit\Spanner; use Google\Cloud\Core\Int64; use Google\Cloud\Spanner\Bytes; @@ -86,7 +86,7 @@ public function testFormatParamsForExecuteSqlArray() } /** - * @expectedException InvalidArgumentException + * @expectedException BadMethodCallException */ public function testFormatParamsForExecuteSqlArrayInvalidAssoc() { @@ -96,7 +96,7 @@ public function testFormatParamsForExecuteSqlArrayInvalidAssoc() } /** - * @expectedException InvalidArgumentException + * @expectedException BadMethodCallException */ public function testFormatParamsForExecuteSqlInvalidTypes() { From 097170fd1bf4a8b52bd091b8c1ecf774d5f65684 Mon Sep 17 00:00:00 2001 From: Dave Supplee Date: Fri, 5 May 2017 15:51:29 -0400 Subject: [PATCH 04/11] Buffer result sets and retry only with error code unavailable --- src/Spanner/Connection/Grpc.php | 2 - src/Spanner/Database.php | 8 +- src/Spanner/Result.php | 226 +++++++++++++++-------- src/Spanner/Session/CacheSessionPool.php | 7 +- tests/snippets/Spanner/DatabaseTest.php | 3 +- tests/snippets/Trace/TraceClientTest.php | 4 +- tests/system/Spanner/TransactionTest.php | 13 +- 7 files changed, 161 insertions(+), 102 deletions(-) diff --git a/src/Spanner/Connection/Grpc.php b/src/Spanner/Connection/Grpc.php index a02d4f179364..b78a98cc22e3 100644 --- a/src/Spanner/Connection/Grpc.php +++ b/src/Spanner/Connection/Grpc.php @@ -414,10 +414,8 @@ public function executeStreamingSql(array $args) { $params = $this->pluck('params', $args); if ($params) { - // print_r($this->formatStructForApi($params)); $args['params'] = (new protobuf\Struct) ->deserialize($this->formatStructForApi($params), $this->codec); - // var_dump($args['params']);exit; } if (isset($args['paramTypes']) && is_array($args['paramTypes'])) { diff --git a/src/Spanner/Database.php b/src/Spanner/Database.php index e0099b7bb129..9f24360819ab 100644 --- a/src/Spanner/Database.php +++ b/src/Spanner/Database.php @@ -1303,8 +1303,8 @@ public function __destruct() /** * Create a new session. * - * Sessions are handled behind the scenes and this method not need to be - * called directly. + * Sessions are handled behind the scenes and this method does not need to + * be called directly. * * @access private * @param array $options [optional] Configuration options. @@ -1324,8 +1324,8 @@ public function createSession(array $options = []) * point. To see the operations that can be performed on a session please * see {@see Google\Cloud\Spanner\Session\Session}. * - * Sessions are handled behind the scenes and this method not need to be - * called directly. + * Sessions are handled behind the scenes and this method does not need to + * be called directly. * * @access private * @param string $name The session's name. diff --git a/src/Spanner/Result.php b/src/Spanner/Result.php index 727f2e6fe3b8..3e68ce94def5 100644 --- a/src/Spanner/Result.php +++ b/src/Spanner/Result.php @@ -17,7 +17,9 @@ namespace Google\Cloud\Spanner; +use Grpc; use Google\Cloud\Core\Exception\ServiceException; +use Google\Cloud\Core\ExponentialBackoff; use Google\Cloud\Spanner\Session\Session; use Google\Cloud\Spanner\Session\SessionPoolInterface; use Google\Cloud\Spanner\Timestamp; @@ -39,16 +41,18 @@ */ class Result implements \IteratorAggregate { - /** - * @var array|null - */ - private $cachedValues; + const BUFFER_RESULT_LIMIT = 10; /** * @var array */ private $columns = []; + /** + * @var int + */ + private $columnCount; + /** * @var ValueMapper */ @@ -64,6 +68,11 @@ class Result implements \IteratorAggregate */ private $operation; + /** + * @var int + */ + private $retries; + /** * @var string|null */ @@ -100,25 +109,29 @@ class Result implements \IteratorAggregate * @param \Generator $resultGenerator Reads rows from Google Cloud Spanner. * @param string $transactionContext The transaction's context. * @param ValueMapper $mapper Maps values. + * @param int $retries Number of attempts to resume a broken stream, assuming + * a resume token is present. **Defaults to** 3. */ public function __construct( Operation $operation, Session $session, callable $call, $transactionContext, - ValueMapper $mapper + ValueMapper $mapper, + $retries = 3 ) { $this->operation = $operation; $this->session = $session; $this->call = $call; $this->transactionContext = $transactionContext; $this->mapper = $mapper; + $this->retries = $retries; } /** - * Return the formatted and decoded rows. - * - * If the stream is interrupted an attempt will be made to resume. + * Return the formatted and decoded rows. If the stream is interrupted and + * a resume token is available, attempts will be made on your behalf to + * resume. * * Example: * ``` @@ -129,20 +142,67 @@ public function __construct( */ public function rows() { + $bufferedResults = []; $call = $this->call; + $generator = $call(); + $shouldRetry = false; + + while ($generator->valid()) { + try { + $result = $generator->current(); + $bufferedResults[] = $result; + $this->setResultData($result); + + if (!isset($result['values'])) { + return; + } + + if (isset($result['resumeToken']) || count($bufferedResults) >= self::BUFFER_RESULT_LIMIT) { + list($yieldableRows, $chunkedResult) = $this->parseRowsFromBufferedResults($bufferedResults); + + foreach ($yieldableRows as $row) { + yield $this->mapper->decodeValues($this->columns, $row); + } + + // Now that we've yielded all available rows, flush the buffer. + $bufferedResults = []; + $shouldRetry = isset($result['resumeToken']) + ? true + : false; + + // If the last item in the buffer had a chunked value let's + // hold on to it so we can stitch it together into a yieldable + // result. + if ($chunkedResult) { + $bufferedResults[] = $chunkedResult; + } + } + + $generator->next(); + } catch (\Exception $ex) { + if ($shouldRetry && $ex->getCode() === Grpc\STATUS_UNAVAILABLE) { + $backoff = new ExponentialBackoff($this->retries, function (\Exception $ex) { + return $ex->getCode() === Grpc\STATUS_UNAVAILABLE + ? true + : false; + }); + + // Attempt to resume using our last stored resume token. If we + // successfully resume, flush the buffer. + $generator = $backoff->execute($call, [$this->resumeToken]); + $bufferedResults = []; + } - try { - foreach ($this->getRows($call()) as $row) { - yield $row; - } - } catch (ServiceException $ex) { - if (!$this->resumeToken) { throw $ex; } + } - // If we have a token, attempt to resume - foreach ($this->getRows($call($this->resumeToken)) as $row) { - yield $row; + // If there are any results remaining in the buffer, yield them. + if ($bufferedResults) { + list($yieldableRows, $chunkedResult) = $this->parseRowsFromBufferedResults($bufferedResults); + + foreach ($yieldableRows as $row) { + yield $this->mapper->decodeValues($this->columns, $row); } } } @@ -230,6 +290,7 @@ public function transaction() /** * @access private + * @return \Generator */ public function getIterator() { @@ -237,91 +298,100 @@ public function getIterator() } /** - * Yields rows from a partial result set. - * - * @return \Generator + * @param array $bufferedResults + * @return array */ - private function getRowsFromPartial(array $partial) + private function parseRowsFromBufferedResults(array $bufferedResults) { - $this->stats = isset($partial['stats']) ? $partial['stats'] : null; - $this->resumeToken = isset($partial['resumeToken']) ? $partial['resumeToken'] : null; + $values = []; + $chunkedResult = null; + $shouldMergeValues = isset($bufferedResults[0]['chunkedValue']); + + foreach ($bufferedResults as $key => $result) { + if ($key === 0) { + $values = $bufferedResults[0]['values']; + continue; + } + + $values = $shouldMergeValues + ? $this->mergeValues($values, $result['values']) + : array_merge($values, $result['values']); + $shouldMergeValues = (isset($result['chunkedValue'])) + ? true + : false; + } + + $yieldableRows = array_chunk($values, $this->columnCount); - if (isset($partial['metadata'])) { - $this->metadata = $partial['metadata']; - $this->columns = $partial['metadata']['rowType']['fields']; + if (isset($result['chunkedValue'])) { + $chunkedResult = [ + 'values' => array_pop($yieldableRows), + 'chunkedValue' => true + ]; } - if (isset($partial['metadata']['transaction']['id'])) { + return [ + $yieldableRows, + $chunkedResult + ]; + } + + /** + * @param array $result + */ + private function setResultData(array $result) + { + $this->stats = isset($result['stats']) + ? $result['stats'] + : null; + + if (isset($result['resumeToken'])) { + $this->resumeToken = $result['resumeToken']; + } + + if (isset($result['metadata'])) { + $this->metadata = $result['metadata']; + $this->columns = $result['metadata']['rowType']['fields']; + $this->columnCount = count($this->columns); + } + + if (isset($result['metadata']['transaction']['id'])) { if ($this->transactionContext === SessionPoolInterface::CONTEXT_READ) { $this->snapshot = $this->operation->createSnapshot( $this->session, - $partial['metadata']['transaction'] + $result['metadata']['transaction'] ); } else { $this->transaction = $this->operation->createTransaction( $this->session, - $partial['metadata']['transaction'] + $result['metadata']['transaction'] ); } } - - if ($this->cachedValues) { - $partial['values'] = $this->mergeValues($this->cachedValues, $partial['values']); - $this->cachedValues = null; - } - - if (isset($partial['chunkedValue'])) { - $this->cachedValues = $partial['values']; - return; - } - - $rows = []; - $columnCount = count($this->columns); - - if ($columnCount > 0 && isset($partial['values'])) { - $rows = array_chunk($partial['values'], $columnCount); - } - - foreach ($rows as $row) { - yield $this->mapper->decodeValues($this->columns, $row); - } } /** * Merge result set values together. * - * @param array $cached - * @param array $new - * @return mixed + * @param array $set1 + * @param array $set2 + * @return array */ - private function mergeValues(array $cached, array $new) + private function mergeValues(array $set1, array $set2) { - $lastCachedItem = array_pop($cached); - $firstNewItem = array_shift($new); - $item = $firstNewItem; - - if (is_string($lastCachedItem) && is_string($firstNewItem)) { - $item = $lastCachedItem . $firstNewItem; - } elseif (is_array($lastCachedItem)) { - $item = $this->mergeValues($lastCachedItem, $firstNewItem); + $lastItemSet1 = array_pop($set1); + $firstItemSet2 = array_shift($set2); + $item = $firstItemSet2; + + if (is_string($lastItemSet1) && is_string($firstItemSet2)) { + $item = $lastItemSet1 . $firstItemSet2; + } elseif (is_array($lastItemSet1)) { + $item = $this->mergeValues($lastItemSet1, $firstItemSet2); } else { - array_push($cached, $lastCachedItem); + array_push($set1, $lastItemSet1); } - array_push($cached, $item); - return array_merge($cached, $new); - } - - /** - * @param \Generator $results - * @return \Generator - */ - private function getRows(\Generator $results) - { - foreach ($results as $partial) { - foreach ($this->getRowsFromPartial($partial) as $row) { - yield $row; - } - } + array_push($set1, $item); + return array_merge($set1, $set2); } } diff --git a/src/Spanner/Session/CacheSessionPool.php b/src/Spanner/Session/CacheSessionPool.php index 2d8702d2cb1e..b67737eec5b5 100644 --- a/src/Spanner/Session/CacheSessionPool.php +++ b/src/Spanner/Session/CacheSessionPool.php @@ -26,7 +26,7 @@ /** * This session pool implementation accepts a PSR-6 compatible cache - * implementation and utilizies it to store sessions between requests. + * implementation and utilizes it to store sessions between requests. * * Please note that if you configure a high minimum session value the first * request and any after a period of inactivity greater than an hour (the point @@ -65,7 +65,7 @@ class CacheSessionPool implements SessionPoolInterface * @var array */ private static $defaultConfig = [ - 'maxSessions' => PHP_INT_MAX, + 'maxSessions' => 500, 'minSessions' => 1, 'shouldWaitForSession' => true, 'maxCyclesToWaitForSession' => 30, @@ -252,7 +252,8 @@ public function release(Session $session) } /** - * Clear the session pool. + * Clear the session pool. Please note that this simply removes sessions + * data from the cache and does not delete the sessions themselves. */ public function clear() { diff --git a/tests/snippets/Spanner/DatabaseTest.php b/tests/snippets/Spanner/DatabaseTest.php index bbc7910928a9..6c7488a619b4 100644 --- a/tests/snippets/Spanner/DatabaseTest.php +++ b/tests/snippets/Spanner/DatabaseTest.php @@ -378,8 +378,7 @@ public function testRunTransactionRollback() 'rowType' => [ 'fields' => [] ] - ], - 'values' => [] + ] ])); $this->stubOperation(); diff --git a/tests/snippets/Trace/TraceClientTest.php b/tests/snippets/Trace/TraceClientTest.php index 6d4de01803e4..e38d76260b43 100644 --- a/tests/snippets/Trace/TraceClientTest.php +++ b/tests/snippets/Trace/TraceClientTest.php @@ -35,8 +35,8 @@ class TraceClientTest extends SnippetTestCase public function setUp() { $this->connection = $this->prophesize(ConnectionInterface::class); - $this->client = new \TraceClientStub; - $this->client->setConnection($this->connection->reveal()); + $this->client = \Google\Cloud\Dev\stub(TraceClient::class); + $this->client->___setProperty('connection', $this->connection->reveal()); } public function testClass() diff --git a/tests/system/Spanner/TransactionTest.php b/tests/system/Spanner/TransactionTest.php index 66f3ff1613f0..b9494cb837e9 100644 --- a/tests/system/Spanner/TransactionTest.php +++ b/tests/system/Spanner/TransactionTest.php @@ -22,7 +22,7 @@ use Google\Cloud\Spanner\Timestamp; /** - * @group spannerz + * @group spanner */ class TransactionTest extends SpannerTestCase { @@ -37,7 +37,7 @@ public static function setUpBeforeClass() 'name' => uniqid(self::TESTING_PREFIX), 'birthday' => new Date(new \DateTime('2000-01-01')) ]; - echo 'inserting row'.PHP_EOL; + self::$database->insert(self::TEST_TABLE_NAME, self::$row); } @@ -100,16 +100,7 @@ public function testExactTimestampRead() list($keySet, $cols) = $this->readArgs(); - echo "Cached row data (should match snapshot result)".PHP_EOL; - print_r(self::$row); - $res = $snapshot->read(self::TEST_TABLE_NAME, $keySet, $cols)->rows(); - echo PHP_EOL."Snapshot Result". PHP_EOL; - print_r(iterator_to_array($res)); - - echo PHP_EOL."Database Result". PHP_EOL; - print_r(iterator_to_array($db->read(self::TEST_TABLE_NAME, $keySet, $cols)->rows())); - exit; $row = $res->current(); $this->assertEquals($ts->get(), $snapshot->readTimestamp()->get()); From 201e6b744459553a9c74073be24460024fc9a81b Mon Sep 17 00:00:00 2001 From: jdpedrie Date: Mon, 8 May 2017 14:54:48 -0400 Subject: [PATCH 05/11] Fix parameterization and value mapping Add system test coverage. Add more read system tests and fix KeyRange issues Spanner transaction system tests Add remaining p0 spanner system tests --- src/Core/GrpcRequestWrapper.php | 23 +- src/Core/GrpcTrait.php | 2 +- src/Core/PhpArray.php | 6 +- src/Spanner/Connection/Grpc.php | 125 ++++++- src/Spanner/Database.php | 17 +- src/Spanner/KeyRange.php | 46 ++- src/Spanner/Operation.php | 39 ++- src/Spanner/Transaction.php | 45 ++- src/Spanner/TransactionalReadTrait.php | 13 + src/Spanner/V1/SpannerClient.php | 2 +- src/Spanner/ValueMapper.php | 63 +++- tests/system/Spanner/ReadTest.php | 389 +++++++++++++++++++++ tests/system/Spanner/SnapshotTest.php | 189 +++++++++- tests/system/Spanner/SpannerTestCase.php | 7 + tests/system/Spanner/TransactionTest.php | 209 +++++++++-- tests/system/Spanner/WriteTest.php | 375 ++++++++++++++++++++ tests/unit/Spanner/Connection/GrpcTest.php | 34 +- tests/unit/Spanner/DatabaseTest.php | 12 +- tests/unit/Spanner/KeyRangeTest.php | 2 +- tests/unit/Spanner/TransactionTest.php | 19 + tests/unit/Spanner/ValueMapperTest.php | 3 +- 21 files changed, 1451 insertions(+), 169 deletions(-) create mode 100644 tests/system/Spanner/ReadTest.php create mode 100644 tests/system/Spanner/WriteTest.php diff --git a/src/Core/GrpcRequestWrapper.php b/src/Core/GrpcRequestWrapper.php index ffe5066121ac..3d89d417a9e0 100644 --- a/src/Core/GrpcRequestWrapper.php +++ b/src/Core/GrpcRequestWrapper.php @@ -170,7 +170,7 @@ private function handleResponse($response) if ($response instanceof Message) { $res = $response->serialize($this->codec); - return $this->convertNulls($res); + return $res; } if ($response instanceof OperationResponse) { @@ -195,7 +195,7 @@ private function handleStream(ServerStream $response) try { foreach ($response->readAll() as $count => $result) { $res = $result->serialize($this->codec); - yield $this->convertNulls($res); + yield $res; } } catch (\Exception $ex) { throw $this->convertToGoogleException($ex); @@ -259,23 +259,4 @@ private function convertToGoogleException(ApiException $ex) return new $exception($ex->getMessage(), $ex->getCode(), $ex, $metadata); } - - /** - * Convert NullValue types to PHP null. - * - * @param array [ref] $result - * @return array - */ - private function convertNulls(array &$result) - { - foreach ($result as $key => $value) { - if (is_array($value) && array_key_exists('nullValue', $value) && $value['nullValue'][0] === null) { - $result[$key] = null; - } elseif (is_array($value)) { - $result[$key] = $this->convertNulls($value); - } - } - - return $result; - } } diff --git a/src/Core/GrpcTrait.php b/src/Core/GrpcTrait.php index 9867f26d8803..46313151a373 100644 --- a/src/Core/GrpcTrait.php +++ b/src/Core/GrpcTrait.php @@ -181,7 +181,7 @@ private function formatValueForApi($value) case 'NULL': return ['null_value' => protobuf\NullValue::NULL_VALUE]; case 'array': - if ($this->isAssoc($value)) { + if (!empty($value) && $this->isAssoc($value)) { return ['struct_value' => $this->formatStructForApi($value)]; } diff --git a/src/Core/PhpArray.php b/src/Core/PhpArray.php index cf8a5fd360b7..882b2da72f5d 100644 --- a/src/Core/PhpArray.php +++ b/src/Core/PhpArray.php @@ -159,7 +159,7 @@ protected function decodeMessage(Protobuf\Message $message, $data) protected function filterValue($value, Protobuf\Field $field) { if (trim($field->getReference(), '\\') === NullValue::class) { - return 0; + return null; } if ($value instanceof Protobuf\Message) { @@ -195,7 +195,7 @@ protected function filterValue($value, Protobuf\Field $field) foreach ($fields as $field) { $name = $field->getName(); - if ($val->$name) { + if ($val->$name !== null) { $vals[] = $this->filterValue($val->$name, $field); } } @@ -209,7 +209,7 @@ protected function filterValue($value, Protobuf\Field $field) foreach ($fields as $field) { $name = $field->getName(); - if ($value->$name) { + if ($value->$name !== null) { return $this->filterValue($value->$name, $field); } } diff --git a/src/Spanner/Connection/Grpc.php b/src/Spanner/Connection/Grpc.php index b78a98cc22e3..d5a5f9b9ca22 100644 --- a/src/Spanner/Connection/Grpc.php +++ b/src/Spanner/Connection/Grpc.php @@ -414,8 +414,19 @@ public function executeStreamingSql(array $args) { $params = $this->pluck('params', $args); if ($params) { - $args['params'] = (new protobuf\Struct) - ->deserialize($this->formatStructForApi($params), $this->codec); + $args['params'] = []; + + $args['params'] = new protobuf\Struct; + + foreach ($params as $key => $param) { + $field = $this->fieldValue($param); + + $fields = new protobuf\Struct\FieldsEntry; + $fields->setKey($key); + $fields->setValue($field); + + $args['params']->addFields($fields); + } } if (isset($args['paramTypes']) && is_array($args['paramTypes'])) { @@ -462,19 +473,10 @@ public function beginTransaction(array $args) { $options = new TransactionOptions; - $transactionOptions = $this->pluck('transactionOptions', $args); + $transactionOptions = $this->formatTransactionOptions($this->pluck('transactionOptions', $args)); if (isset($transactionOptions['readOnly'])) { - $ro = $transactionOptions['readOnly']; - if (isset($ro['minReadTimestamp'])) { - $ro['minReadTimestamp'] = $this->formatTimestampForApi($ro['minReadTimestamp']); - } - - if (isset($ro['readTimestamp'])) { - $ro['readTimestamp'] = $this->formatTimestampForApi($ro['readTimestamp']); - } - $readOnly = (new TransactionOptions\ReadOnly) - ->deserialize($ro, $this->codec); + ->deserialize($transactionOptions['readOnly'], $this->codec); $options->setReadOnly($readOnly); } else { @@ -513,10 +515,18 @@ public function commit(array $args) break; default: - $data['values'] = $this->formatListForApi($data['values']); + $operation = new Mutation\Write; + $operation->setTable($data['table']); + $operation->setColumns($data['columns']); - $operation = (new Mutation\Write) - ->deserialize($data, $this->codec); + $list = new protobuf\ListValue; + foreach ($data['values'] as $key => $param) { + $field = $this->fieldValue($param); + + $list->addValues($field); + } + + $operation->setValues($list); break; } @@ -621,7 +631,11 @@ private function formatKeySet(array $keySet) { $keys = $this->pluck('keys', $keySet, false); if ($keys) { - $keySet['keys'] = $this->formatListForApi($keys); + $keySet['keys'] = []; + + foreach ($keys as $key) { + $keySet['keys'][] = $this->formatListForApi([$key]); + } } if (isset($keySet['ranges'])) { @@ -649,7 +663,17 @@ private function createTransactionSelector(array &$args) { $selector = new TransactionSelector; if (isset($args['transaction'])) { - $selector = $selector->deserialize($this->pluck('transaction', $args), $this->codec); + $transaction = $this->pluck('transaction', $args); + + if (isset($transaction['singleUse'])) { + $transaction['singleUse'] = $this->formatTransactionOptions($transaction['singleUse']); + } + + if (isset($transaction['begin'])) { + $transaction['begin'] = $this->formatTransactionOptions($transaction['begin']); + } + + $selector = $selector->deserialize($transaction, $this->codec); } elseif (isset($args['transactionId'])) { $selector = $selector->deserialize(['id' => $this->pluck('transactionId', $args)], $this->codec); } @@ -677,4 +701,69 @@ private function instanceObject(array &$args, $required = false) 'labels' => $labels ]), $this->codec); } + + /** + * @param mixed $param + * @return Value + */ + private function fieldValue($param) + { + $field = new protobuf\Value; + $value = $this->formatValueForApi($param); + + switch (array_keys($value)[0]) { + case 'string_value': + $setter = 'setStringValue'; + break; + case 'number_value': + $setter = 'setNumberValue'; + break; + case 'bool_value': + $setter = 'setBoolValue'; + break; + case 'null_value': + $setter = 'setNullValue'; + break; + case 'struct_value': + $setter = 'setStructValue'; + break; + case 'list_value': + $setter = 'setListValue'; + $list = new protobuf\ListValue; + foreach ($param as $item) { + $list->addValues($this->fieldValue($item)); + } + + $value = $list; + + break; + } + + $value = is_array($value) ? current($value) : $value; + $field->$setter($value); + + return $field; + } + + /** + * @param array $transactionOptions + * @return array + */ + private function formatTransactionOptions(array $transactionOptions) + { + if (isset($transactionOptions['readOnly'])) { + $ro = $transactionOptions['readOnly']; + if (isset($ro['minReadTimestamp'])) { + $ro['minReadTimestamp'] = $this->formatTimestampForApi($ro['minReadTimestamp']); + } + + if (isset($ro['readTimestamp'])) { + $ro['readTimestamp'] = $this->formatTimestampForApi($ro['readTimestamp']); + } + + $transactionOptions['readOnly'] = $ro; + } + + return $transactionOptions; + } } diff --git a/src/Spanner/Database.php b/src/Spanner/Database.php index 9f24360819ab..d2c5c1340055 100644 --- a/src/Spanner/Database.php +++ b/src/Spanner/Database.php @@ -96,7 +96,7 @@ class Database use LROTrait; use TransactionConfigurationTrait; - const MAX_RETRIES = 3; + const MAX_RETRIES = 10; /** * @var ConnectionInterface @@ -634,7 +634,7 @@ public function transaction(array $options = []) * Configuration Options * * @type int $maxRetries The number of times to attempt to apply the - * operation before failing. **Defaults to ** `3`. + * operation before failing. **Defaults to ** `10`. * @type bool $singleUse If true, a Transaction ID will not be allocated * up front. Instead, the transaction will be considered * "single-use", and may be used for only a single operation. Note @@ -658,6 +658,10 @@ public function runTransaction(callable $operation, array $options = []) $attempt = 0; $startTransactionFn = function ($session, $options) use (&$attempt) { + if ($attempt > 0) { + $options['isRetry'] = true; + } + $transaction = $this->operation->transaction($session, $options); $attempt++; @@ -681,7 +685,9 @@ public function runTransaction(callable $operation, array $options = []) $res = call_user_func($operation, $transaction); - if ($transaction->state() === Transaction::STATE_ACTIVE) { + $active = $transaction->state() === Transaction::STATE_ACTIVE; + $singleUse = $transaction->type() === Transaction::TYPE_SINGLE_USE; + if ($active && !$singleUse) { $transaction->rollback($options); throw new \RuntimeException('Transactions must be rolled back or committed.'); } @@ -1091,7 +1097,10 @@ public function delete($table, KeySet $keySet, array $options = []) * `ValueMapper::TYPE_FLOAT64`, `ValueMapper::TYPE_TIMESTAMP`, * `ValueMapper::TYPE_DATE`, `ValueMapper::TYPE_STRING`, * `ValueMapper::TYPE_BYTES`, `ValueMapper::TYPE_ARRAY` and - * `ValueMapper::TYPE_STRUCT`. + * `ValueMapper::TYPE_STRUCT`. If the parameter type is an array, + * the type should be given as an array, where the first element + * is `ValueMapper::TYPE_ARRAY` and the second element is the + * array type, for instance `[ValueMapper::TYPE_ARRAY, ValueMapper::TYPE_INT64]`. * @type bool $returnReadTimestamp If true, the Cloud Spanner-selected * read timestamp is included in the Transaction message that * describes the transaction. diff --git a/src/Spanner/KeyRange.php b/src/Spanner/KeyRange.php index 3e6b00324467..759a779d6f94 100644 --- a/src/Spanner/KeyRange.php +++ b/src/Spanner/KeyRange.php @@ -87,27 +87,33 @@ class KeyRange * * @type string $startType Either "open" or "closed". Use constants * `KeyRange::TYPE_OPEN` and `KeyRange::TYPE_CLOSED` for - * guaranteed correctness. + * guaranteed correctness. **Defaults to** `KeyRange::TYPE_OPEN`. * @type array $start The key with which to start the range. * @type string $endType Either "open" or "closed". Use constants * `KeyRange::TYPE_OPEN` and `KeyRange::TYPE_CLOSED` for - * guaranteed correctness. + * guaranteed correctness. **Defaults to** `KeyRange::TYPE_OPEN`. * @type array $end The key with which to end the range. * } */ public function __construct(array $options = []) { $options += [ - 'startType' => null, + 'startType' => KeyRange::TYPE_OPEN, 'start' => null, - 'endType' => null, + 'endType' => KeyRange::TYPE_OPEN, 'end' => null ]; - $this->startType = $options['startType']; - $this->start = $options['start']; - $this->endType = $options['endType']; - $this->end = $options['end']; + $this->startType = $this->fromDefinition($options['startType'], 'start'); + $this->endType = $this->fromDefinition($options['endType'], 'end'); + + $this->start = ($options['start'] === null || is_array($options['start'])) + ? $options['start'] + : [$options['start']]; + + $this->end = ($options['end'] === null || is_array($options['end'])) + ? $options['end'] + : [$options['end']]; } /** @@ -165,14 +171,7 @@ public function start() */ public function setStart($type, array $start) { - if (!in_array($type, array_keys($this->definition))) { - throw new \InvalidArgumentException(sprintf( - 'Invalid KeyRange type. Allowed values are %s', - implode(', ', array_keys($this->definition)) - )); - } - - $rangeKey = $this->definition[$type]['start']; + $rangeKey = $this->fromDefinition($type, 'start'); $this->startType = $rangeKey; $this->start = $start; @@ -216,7 +215,7 @@ public function setEnd($type, array $end) )); } - $rangeKey = $this->definition[$type]['end']; + $rangeKey = $this->fromDefinition($type, 'end'); $this->endType = $rangeKey; $this->end = $end; @@ -257,4 +256,17 @@ public function keyRangeObject() $this->endType => $this->end ]; } + + private function fromDefinition($type, $startOrEnd) + { + if (!in_array($type, array_keys($this->definition))) { + throw new \InvalidArgumentException(sprintf( + 'Invalid KeyRange %s type. Allowed values are %s.', + $startOrEnd, + implode(', ', array_keys($this->definition)) + )); + } + + return $this->definition[$type][$startOrEnd]; + } } diff --git a/src/Spanner/Operation.php b/src/Spanner/Operation.php index bc4fde898770..54ce03b2ca50 100644 --- a/src/Spanner/Operation.php +++ b/src/Spanner/Operation.php @@ -168,7 +168,8 @@ public function execute(Session $session, $sql, array $options = []) ]; $parameters = $this->pluck('parameters', $options); - $options += $this->mapper->formatParamsForExecuteSql($parameters, $options['types']); + $types = $this->pluck('types', $options); + $options += $this->mapper->formatParamsForExecuteSql($parameters, $types); $context = $this->pluck('transactionContext', $options); @@ -242,13 +243,17 @@ public function read(Session $session, $table, KeySet $keySet, array $columns, a * up front. Instead, the transaction will be considered * "single-use", and may be used for only a single operation. * **Defaults to** `false`. + * @type bool $isRetry If true, the resulting transaction will indicate + * that it is the result of a retry operation. **Defaults to** + * `false`. * } * @return Transaction */ public function transaction(Session $session, array $options = []) { $options += [ - 'singleUse' => false + 'singleUse' => false, + 'isRetry' => false ]; if (!$options['singleUse']) { @@ -296,15 +301,20 @@ public function snapshot(Session $session, array $options = []) * * @param Session $session The session the transaction belongs to. * @param array $res [optional] The createTransaction response. + * @param array $options [optional] Options for the transaction object. * @return Transaction */ - public function createTransaction(Session $session, array $res = []) + public function createTransaction(Session $session, array $res = [], array $options = []) { $res += [ 'id' => null ]; - return new Transaction($this, $session, $res['id']); + $options['isRetry'] = isset($options['isRetry']) + ? $options['isRetry'] + : false; + + return new Transaction($this, $session, $res['id'], $options['isRetry']); } /** @@ -356,25 +366,18 @@ private function beginTransaction(Session $session, array $options = []) */ private function flattenKeySet(KeySet $keySet) { - $keyRanges = $keySet->ranges(); - if ($keyRanges) { - $ranges = []; - foreach ($keyRanges as $range) { - $types = $range->types(); - - $start = $range->start(); - $range->setStart($types['start'], $this->mapper->encodeValuesAsSimpleType($start)); + $keys = $keySet->keySetObject(); - $end = $range->end(); - $range->setEnd($types['end'], $this->mapper->encodeValuesAsSimpleType($end)); + if (!empty($keys['ranges'])) { + foreach ($keys['ranges'] as $index => $range) { + foreach ($range as $type => $rangeKeys) { + $range[$type] = $this->mapper->encodeValuesAsSimpleType($rangeKeys); + } - $ranges[] = $range; + $keys['ranges'][$index] = $range; } - - $keySet->setRanges($ranges); } - $keys = $keySet->keySetObject(); if (!empty($keys['keys'])) { $keys['keys'] = $this->mapper->encodeValuesAsSimpleType($keys['keys']); } diff --git a/src/Spanner/Transaction.php b/src/Spanner/Transaction.php index 86c4ae32e448..7296c7efa35f 100644 --- a/src/Spanner/Transaction.php +++ b/src/Spanner/Transaction.php @@ -91,6 +91,19 @@ * @type array $parameters A key/value array of Query Parameters, where * the key is represented in the query string prefixed by a `@` * symbol. + * @type array $types A key/value array of Query Parameter types. + * Generally, Google Cloud PHP can infer types. Explicit type + * definitions are only necessary for null parameter values. + * Accepted values are defined as constants on + * {@see Google\Cloud\Spanner\ValueMapper}, and are as follows: + * `ValueMapper::TYPE_BOOL`, `ValueMapper::TYPE_INT64`, + * `ValueMapper::TYPE_FLOAT64`, `ValueMapper::TYPE_TIMESTAMP`, + * `ValueMapper::TYPE_DATE`, `ValueMapper::TYPE_STRING`, + * `ValueMapper::TYPE_BYTES`, `ValueMapper::TYPE_ARRAY` and + * `ValueMapper::TYPE_STRUCT`. If the parameter type is an array, + * the type should be given as an array, where the first element + * is `ValueMapper::TYPE_ARRAY` and the second element is the + * array type, for instance `[ValueMapper::TYPE_ARRAY, ValueMapper::TYPE_INT64]`. * } * @return Result * } @@ -146,6 +159,11 @@ class Transaction implements TransactionalReadInterface */ private $mutations = []; + /** + * @var bool + */ + private $isRetry = false; + /** * @param Operation $operation The Operation instance. * @param Session $session The session to use for spanner interactions. @@ -155,11 +173,13 @@ class Transaction implements TransactionalReadInterface public function __construct( Operation $operation, Session $session, - $transactionId = null + $transactionId = null, + $isRetry = false ) { $this->operation = $operation; $this->session = $session; $this->transactionId = $transactionId; + $this->isRetry = $isRetry; $this->type = $transactionId ? self::TYPE_PRE_ALLOCATED @@ -475,6 +495,29 @@ public function state() return $this->state; } + /** + * Check whether the current transaction is a retry transaction. + * + * When using {@see Google\Cloud\Spanner\Database::runTransaction()}, + * transactions are automatically retried when a conflict causes it to abort. + * In such cases, subsequent invocations of the transaction callable will + * provide a transaction where `$transaction->isRetry()` is true. This can + * be useful for debugging and understanding how code is working. + * + * Example: + * ``` + * if ($transaction->isRetry()) { + * echo 'This is a retry transaction!'; + * } + * ``` + * + * @return bool + */ + public function isRetry() + { + return $this->isRetry; + } + /** * Format, validate and enqueue mutations in the transaction. * diff --git a/src/Spanner/TransactionalReadTrait.php b/src/Spanner/TransactionalReadTrait.php index 5fcbf6042ef4..c220e3ed61ce 100644 --- a/src/Spanner/TransactionalReadTrait.php +++ b/src/Spanner/TransactionalReadTrait.php @@ -71,6 +71,19 @@ trait TransactionalReadTrait * @type array $parameters A key/value array of Query Parameters, where * the key is represented in the query string prefixed by a `@` * symbol. + * @type array $types A key/value array of Query Parameter types. + * Generally, Google Cloud PHP can infer types. Explicit type + * definitions are only necessary for null parameter values. + * Accepted values are defined as constants on + * {@see Google\Cloud\Spanner\ValueMapper}, and are as follows: + * `ValueMapper::TYPE_BOOL`, `ValueMapper::TYPE_INT64`, + * `ValueMapper::TYPE_FLOAT64`, `ValueMapper::TYPE_TIMESTAMP`, + * `ValueMapper::TYPE_DATE`, `ValueMapper::TYPE_STRING`, + * `ValueMapper::TYPE_BYTES`, `ValueMapper::TYPE_ARRAY` and + * `ValueMapper::TYPE_STRUCT`. If the parameter type is an array, + * the type should be given as an array, where the first element + * is `ValueMapper::TYPE_ARRAY` and the second element is the + * array type, for instance `[ValueMapper::TYPE_ARRAY, ValueMapper::TYPE_INT64]`. * } * @return Result */ diff --git a/src/Spanner/V1/SpannerClient.php b/src/Spanner/V1/SpannerClient.php index 8bd3507e90d4..21f8dc2bdfbe 100644 --- a/src/Spanner/V1/SpannerClient.php +++ b/src/Spanner/V1/SpannerClient.php @@ -959,7 +959,7 @@ public function streamingRead($session, $table, $columns, $keySet, $optionalArgs $mergedSettings, $this->descriptors['streamingRead'] ); - +// print_r($request->serialize(new PhpArray));exit; return $callable( $request, [], diff --git a/src/Spanner/ValueMapper.php b/src/Spanner/ValueMapper.php index 8118fe72df65..f6a2a238e53e 100644 --- a/src/Spanner/ValueMapper.php +++ b/src/Spanner/ValueMapper.php @@ -28,7 +28,7 @@ class ValueMapper { use ArrayTrait; - const NANO_REGEX = '/\.(\d{1,9})Z/'; + const NANO_REGEX = '/(?:\.(\d{1,9})Z)|(?:Z)/'; const TYPE_BOOL = TypeCode::TYPE_BOOL; const TYPE_INT64 = TypeCode::TYPE_INT64; @@ -90,6 +90,11 @@ public function formatParamsForExecuteSql(array $parameters, array $types = []) } $type = isset($types[$key]) ? $types[$key] : null; + $arrayType = null; + if (is_array($type)) { + $arrayType = $type[1]; + $type = $type[0]; + } if ($type !== null && !in_array($type, $this->allowedTypes)) { throw new \BadMethodCallException(sprintf( @@ -99,7 +104,15 @@ public function formatParamsForExecuteSql(array $parameters, array $types = []) )); } - list ($parameters[$key], $paramTypes[$key]) = $this->paramType($value, $type); + if ($arrayType !== null && !in_array($arrayType, $this->allowedTypes)) { + throw new \BadMethodCallException(sprintf( + 'Type %s given for parameter @%s is not valid.', + $type, + $key + )); + } + + list ($parameters[$key], $paramTypes[$key]) = $this->paramType($value, $type, $arrayType); } return [ @@ -166,7 +179,7 @@ public function createTimestampWithNanos($timestamp) preg_match(self::NANO_REGEX, $timestamp, $matches); $timestamp = preg_replace(self::NANO_REGEX, '.000000Z', $timestamp); - $dt = \DateTimeImmutable::createFromFormat(Timestamp::FORMAT, $timestamp); + $dt = \DateTimeImmutable::createFromFormat(Timestamp::FORMAT, str_replace('..', '.', $timestamp)); return new Timestamp($dt, (isset($matches[1])) ? $matches[1] : 0); } @@ -254,31 +267,47 @@ private function decodeValue($value, array $type) * Create a spanner parameter type value object from a PHP value type. * * @param mixed $value The PHP value + * @param int $givenType + * @param int $arrayType * @return array The Value type */ - private function paramType($value, $givenType = null) + private function paramType($value, $givenType = null, $arrayType = null) { $phpType = gettype($value); switch ($phpType) { case 'boolean': - $type = $this->typeObject(self::TYPE_BOOL); + $type = $this->typeObject($givenType ?: self::TYPE_BOOL); break; case 'integer': $value = (string) $value; - $type = $this->typeObject(self::TYPE_INT64); + $type = $this->typeObject($givenType ?: self::TYPE_INT64); break; case 'double': - $type = $this->typeObject(self::TYPE_FLOAT64); + $type = $this->typeObject($givenType ?: self::TYPE_FLOAT64); + switch ($value) { + case INF: + $value = 'Infinity'; + break; + + case -INF: + $value = '-Infinity'; + break; + } + + if (!is_string($value) && is_nan($value)) { + $value = 'NaN'; + } + break; case 'string': - $type = $this->typeObject(self::TYPE_STRING); + $type = $this->typeObject($givenType ?: self::TYPE_STRING); break; case 'resource': - $type = $this->typeObject(self::TYPE_BYTES); + $type = $this->typeObject($givenType ?: self::TYPE_BYTES); $value = base64_encode(stream_get_contents($value)); break; @@ -287,7 +316,7 @@ private function paramType($value, $givenType = null) break; case 'array': - if ($this->isAssoc($value)) { + if (!empty($value) && $this->isAssoc($value)) { throw new \BadMethodCallException( 'Associative arrays are not supported. Did you mean to call a batch method?' ); @@ -303,13 +332,13 @@ private function paramType($value, $givenType = null) } } - if (count(array_unique($types)) !== 1) { + if (count(array_unique($types)) > 1) { throw new \BadMethodCallException('Array values may not be of mixed type'); } $type = $this->typeObject( self::TYPE_ARRAY, - $this->typeObject($types[0]), + $this->typeObject((isset($types[0])) ? $types[0] : null), 'arrayElementType' ); @@ -317,7 +346,15 @@ private function paramType($value, $givenType = null) break; case 'NULL': - $type = $this->typeObject($givenType); + if ($givenType === self::TYPE_ARRAY) { + $type = $this->typeObject( + $givenType, + $this->typeObject($arrayType), + 'arrayElementType' + ); + } else { + $type = $this->typeObject($givenType); + } break; default: diff --git a/tests/system/Spanner/ReadTest.php b/tests/system/Spanner/ReadTest.php new file mode 100644 index 000000000000..ed6eb1e025ce --- /dev/null +++ b/tests/system/Spanner/ReadTest.php @@ -0,0 +1,389 @@ +updateDdlBatch([ + 'CREATE TABLE '. self::READ_TABLE_NAME .' ( + id INT64 NOT NULL, + val STRING(MAX) NOT NULL, + ) PRIMARY KEY (id)', + 'CREATE TABLE '. self::RANGES_TABLE_NAME .' ( + id INT64 NOT NULL, + val STRING(MAX) NOT NULL, + ) PRIMARY KEY (id, val)' + ])->pollUntilComplete(); + + self::$dataset = self::generateDataset(20, true); + $db->insertBatch(self::RANGES_TABLE_NAME, self::$dataset); + } + + /** + * covers 12 + */ + public function testReadPoint() + { + $dataset = $this->generateDataset(); + + $db = self::$database; + $db->insertBatch(self::READ_TABLE_NAME, $dataset); + + $indexes = array_rand($dataset, 4); + $points = []; + $keys = []; + array_walk($indexes, function ($index) use ($dataset, &$points, &$keys) { + $points[] = $dataset[$index]; + $keys[] = $dataset[$index]['id']; + }); + + $keyset = new KeySet(['keys' => $keys]); + + $res = $db->read(self::READ_TABLE_NAME, $keyset, array_keys($dataset[0])); + $rows = $res->rows(); + foreach ($rows as $index => $row) { + $this->assertTrue(in_array($row, $dataset)); + $this->assertTrue(in_array($row, $points)); + } + } + + // public function rangeProvider() + // { + // return [ + // // single key, open-open + + // ]; + // } + + /** + * covers 8 + */ + public function testRangeReadSingleKeyOpen() + { + $db = self::$database; + + $range = new KeyRange([ + 'start' => self::$dataset[0], + 'end' => self::$dataset[10], + ]); + + $keyset = new KeySet(['ranges' => [$range]]); + + $res = $db->read(self::RANGES_TABLE_NAME, $keyset, array_keys(self::$dataset[0])); + $rows = iterator_to_array($res->rows()); + $this->assertFalse(in_array(self::$dataset[0], $rows)); + $this->assertFalse(in_array(self::$dataset[10], $rows)); + } + + /** + * covers 8 + */ + public function testRangeReadSingleKeyClosed() + { + $db = self::$database; + + $range = new KeyRange([ + 'start' => self::$dataset[0], + 'end' => self::$dataset[10], + 'startType' => KeyRange::TYPE_CLOSED, + 'endType' => KeyRange::TYPE_CLOSED, + ]); + + $keyset = new KeySet(['ranges' => [$range]]); + + $res = $db->read(self::RANGES_TABLE_NAME, $keyset, array_keys(self::$dataset[0])); + $rows = iterator_to_array($res->rows()); + $this->assertTrue(in_array(self::$dataset[0], $rows)); + $this->assertTrue(in_array(self::$dataset[10], $rows)); + } + + /** + * covers 8 + */ + public function testRangeReadSingleKeyOpenClosed() + { + $db = self::$database; + + $range = new KeyRange([ + 'start' => self::$dataset[0], + 'end' => self::$dataset[10], + 'endType' => KeyRange::TYPE_CLOSED + ]); + + $keyset = new KeySet(['ranges' => [$range]]); + + $res = $db->read(self::RANGES_TABLE_NAME, $keyset, array_keys(self::$dataset[0])); + $rows = iterator_to_array($res->rows()); + $this->assertFalse(in_array(self::$dataset[0], $rows)); + $this->assertTrue(in_array(self::$dataset[10], $rows)); + } + + /** + * covers 8 + */ + public function testRangeReadSingleKeyClosedOpen() + { + $db = self::$database; + + $range = new KeyRange([ + 'start' => self::$dataset[0], + 'startType' => KeyRange::TYPE_CLOSED, + 'end' => self::$dataset[10], + ]); + + $keyset = new KeySet(['ranges' => [$range]]); + + $res = $db->read(self::RANGES_TABLE_NAME, $keyset, array_keys(self::$dataset[0])); + $rows = iterator_to_array($res->rows()); + $this->assertTrue(in_array(self::$dataset[0], $rows)); + $this->assertFalse(in_array(self::$dataset[10], $rows)); + } + + /** + * covers 8 + */ + public function testRangeReadPartialKeyOpen() + { + $db = self::$database; + + $range = new KeyRange([ + 'start' => [self::$dataset[0]['id']], + 'end' => [self::$dataset[10]['id']], + ]); + + $keyset = new KeySet(['ranges' => [$range]]); + + $res = $db->read(self::RANGES_TABLE_NAME, $keyset, array_keys(self::$dataset[0])); + $rows = iterator_to_array($res->rows()); + $this->assertFalse(in_array(self::$dataset[0], $rows)); + $this->assertFalse(in_array(self::$dataset[10], $rows)); + } + + /** + * covers 8 + */ + public function testRangeReadPartialKeyClosed() + { + $db = self::$database; + + $range = new KeyRange([ + 'start' => [self::$dataset[0]['id']], + 'end' => [self::$dataset[10]['id']], + 'startType' => KeyRange::TYPE_CLOSED, + 'endType' => KeyRange::TYPE_CLOSED, + ]); + + $keyset = new KeySet(['ranges' => [$range]]); + + $res = $db->read(self::RANGES_TABLE_NAME, $keyset, array_keys(self::$dataset[0])); + $rows = iterator_to_array($res->rows()); + $this->assertTrue(in_array(self::$dataset[0], $rows)); + $this->assertTrue(in_array(self::$dataset[10], $rows)); + } + + /** + * covers 21 + */ + public function testQueryReturnsArrayStruct() + { + $db = self::$database; + + $res = $db->execute('SELECT ARRAY(SELECT STRUCT(1, 2))'); + $row = $res->rows()->current(); + $this->assertEquals($row[0][0], [1,2]); + } + + /** + * covers 23 + */ + public function testBindBoolParameter() + { + $db = self::$database; + + $res = $db->execute('SELECT @param as foo', [ + 'parameters' => [ + 'param' => true + ] + ]); + + $row = $res->rows()->current(); + $this->assertTrue($row['foo']); + } + + /** + * covers 25 + */ + public function testBindInt64Parameter() + { + $db = self::$database; + + $res = $db->execute('SELECT @param as foo', [ + 'parameters' => [ + 'param' => 1337 + ] + ]); + + $row = $res->rows()->current(); + $this->assertEquals(1337, $row['foo']); + } + + /** + * covers 25 + */ + public function testBindInt64ParameterWithInt64Class() + { + $db = self::$database; + + $res = $db->execute('SELECT @param as foo', [ + 'parameters' => [ + 'param' => new Int64('1337') + ] + ]); + + $row = $res->rows()->current(); + $this->assertEquals(1337, $row['foo']); + } + + /** + * covers 26 + */ + public function testBindNullIntParameter() + { + $db = self::$database; + + $res = $db->execute('SELECT @param as foo', [ + 'parameters' => [ + 'param' => null + ], + 'types' => [ + 'param' => ValueMapper::TYPE_INT64 + ] + ]); + + $row = $res->rows()->current(); + $this->assertNull($row['foo']); + } + + /** + * covers 27 + */ + public function testBindFloat64Parameter() + { + $db = self::$database; + + $pi = 3.1415; + $res = $db->execute('SELECT @param as foo', [ + 'parameters' => [ + 'param' => $pi + ] + ]); + + $row = $res->rows()->current(); + $this->assertEquals($pi, $row['foo']); + } + + /** + * covers 29 + */ + public function testBindStringParameter() + { + $db = self::$database; + + $str = 'hello world'; + $res = $db->execute('SELECT @param as foo', [ + 'parameters' => [ + 'param' => $str + ] + ]); + + $row = $res->rows()->current(); + $this->assertEquals($str, $row['foo']); + } + + /** + * covers 31 + */ + public function testBindBytesParameter() + { + $db = self::$database; + + $str = 'hello world'; + $bytes = new Bytes($str); + $res = $db->execute('SELECT @param as foo', [ + 'parameters' => [ + 'param' => $bytes + ] + ]); + + $row = $res->rows()->current(); + $this->assertInstanceOf(Bytes::class, $row['foo']); + $this->assertEquals($str, base64_decode($bytes->formatAsString())); + } + + /** + * covers 40 + */ + public function testBindInt64ArrayParameter() + { + $db = self::$database; + + $arr = [5,4,3,2,1]; + $res = $db->execute('SELECT @param as foo', [ + 'parameters' => [ + 'param' => $arr + ] + ]); + + $row = $res->rows()->current(); + $this->assertEquals($arr, $row['foo']); + } + + private static function generateDataset($count = 20, $ordered = false) + { + $dataset = []; + for ($i = 0; $i < $count; $i++) { + $id = ($ordered) ? $i : self::randId(); + $dataset[] = [ + 'id' => $id, + 'val' => uniqid(self::TESTING_PREFIX) + ]; + } + + return $dataset; + } +} diff --git a/tests/system/Spanner/SnapshotTest.php b/tests/system/Spanner/SnapshotTest.php index 06becac07d3b..e083a8a9e05c 100644 --- a/tests/system/Spanner/SnapshotTest.php +++ b/tests/system/Spanner/SnapshotTest.php @@ -18,39 +18,200 @@ namespace Google\Cloud\Tests\System\Spanner; use Google\Cloud\Spanner\Date; +use Google\Cloud\Spanner\Duration; +use Google\Cloud\Spanner\Timestamp; /** * @group spanner + * @group spanner-snapshot */ class SnapshotTest extends SpannerTestCase { - private $id; + const TABLE_NAME = 'Snapshots'; - public function setUp() + private static $tableName; + + public static function setupBeforeClass() { - $this->id = rand(1,99999); + parent::setUpBeforeClass(); + + self::$tableName = uniqid(self::TABLE_NAME); + + self::$database->updateDdl( + 'CREATE TABLE '. self::$tableName .' ( + id INT64 NOT NULL, + number INT64 NOT NULL + ) PRIMARY KEY (id)' + )->pollUntilComplete(); } - public function testSnapshot() + /** + * covers 63 + * covers 68 + */ + public function testSnapshotStrongRead() { $db = self::$database; - $db->insert('Users', [ - 'id' => $this->id, - 'name' => 'John', - 'birthday' => new Date(new \DateTime('1990-01-01')) + $id = $this->randId(); + $row = [ + 'id' => $id, + 'number' => 1 + ]; + + $db->insert(self::$tableName, $row); + + $snapshot = $db->snapshot(['strong' => true, 'returnReadTimestamp' => true]); + + $newRow = $row; + $newRow['number'] = 2; + $db->replace(self::$tableName, $newRow); + + $res = $this->getRow($snapshot, $id); + $this->assertEquals($res, $row); + $this->assertInstanceOf(Timestamp::class, $snapshot->readTimestamp()); + } + + /** + * covers 64 + * covers 69 + */ + public function testSnapshotExactTimestampRead() + { + $db = self::$database; + + $id = $this->randId(); + $row = [ + 'id' => $id, + 'number' => 1 + ]; + + $db->insert(self::$tableName, $row); + sleep(1); + $ts = new Timestamp(new \DateTimeImmutable); + sleep(1); + + $newRow = $row; + $newRow['number'] = 2; + $db->replace(self::$tableName, $newRow); + + $snapshot = $db->snapshot([ + 'readTimestamp' => $ts, + 'returnReadTimestamp' => true + ]); + + $this->assertEquals($ts->get()->format('U'), $snapshot->readTimestamp()->get()->format('U')); + + $res = $this->getRow($snapshot, $id); + $this->assertEquals($row, $res); + } + + /** + * covers 65 + */ + public function testSnapshotMinReadTimestamp() + { + $db = self::$database; + + $id = $this->randId(); + $row = [ + 'id' => $id, + 'number' => 1 + ]; + + $db->insert(self::$tableName, $row); + sleep(1); + $ts = new Timestamp(new \DateTimeImmutable); + sleep(1); + + $newRow = $row; + $newRow['number'] = 2; + $db->replace(self::$tableName, $newRow); + + $snapshot = $db->snapshot([ + 'minReadTimestamp' => $ts, + 'singleUse' => true + ]); + + $res = $this->getRow($snapshot, $id); + $this->assertEquals($res, $newRow); + } + + /** + * covers 66 + * covers 70 + */ + public function testSnapshotExactStaleness() + { + $db = self::$database; + + $id = $this->randId(); + $row = [ + 'id' => $id, + 'number' => 1 + ]; + + $db->insert(self::$tableName, $row); + sleep(1); + $ts = new Timestamp(new \DateTimeImmutable); + sleep(1); + + $newRow = $row; + $newRow['number'] = 2; + $db->replace(self::$tableName, $newRow); + + $duration = new Duration(1); + + $snapshot = $db->snapshot([ + 'exactStaleness' => $duration, + 'returnReadTimestamp' => true + ]); + + $this->assertEquals($ts->get()->format('U'), $snapshot->readTimestamp()->get()->format('U')); + + $res = $this->getRow($snapshot, $id); + $this->assertEquals($row, $res); + } + + /** + * covers 67 + */ + public function testSnapshotMaxStaleness() + { + $db = self::$database; + + $id = $this->randId(); + $row = [ + 'id' => $id, + 'number' => 1 + ]; + + $db->insert(self::$tableName, $row); + sleep(1); + $ts = new Timestamp(new \DateTimeImmutable); + sleep(1); + + $newRow = $row; + $newRow['number'] = 2; + $db->replace(self::$tableName, $newRow); + + $duration = new Duration(1); + + $snapshot = $db->snapshot([ + 'maxStaleness' => $duration, + 'returnReadTimestamp' => true, + 'singleUse' => true ]); - $snapshot = $db->snapshot(); - $row = $this->getRow($snapshot); - $this->assertEquals('John', $row['name']); + $res = $this->getRow($snapshot, $id); + $this->assertEquals($res, $newRow); } - private function getRow($client) + private function getRow($client, $id) { - $result = $client->execute('SELECT * FROM Users WHERE id=@id', [ + $result = $client->execute('SELECT * FROM '. self::$tableName .' WHERE id=@id', [ 'parameters' => [ - 'id' => $this->id + 'id' => $id ] ]); diff --git a/tests/system/Spanner/SpannerTestCase.php b/tests/system/Spanner/SpannerTestCase.php index 61c6f406182d..c96c597d02ad 100644 --- a/tests/system/Spanner/SpannerTestCase.php +++ b/tests/system/Spanner/SpannerTestCase.php @@ -34,6 +34,7 @@ class SpannerTestCase extends \PHPUnit_Framework_TestCase protected static $client; protected static $instance; protected static $database; + protected static $database2; protected static $deletionQueue = []; private static $hasSetUp = false; @@ -73,6 +74,7 @@ public static function setUpBeforeClass() )->pollUntilComplete(); self::$database = $db; + self::$database2 = self::$client->connect(self::INSTANCE_NAME, $dbName); } public static function tearDownFixtures() @@ -83,4 +85,9 @@ public static function tearDownFixtures() $backoff->execute($item); } } + + public static function randId() + { + return rand(1,9999999); + } } diff --git a/tests/system/Spanner/TransactionTest.php b/tests/system/Spanner/TransactionTest.php index b9494cb837e9..cd8223678a02 100644 --- a/tests/system/Spanner/TransactionTest.php +++ b/tests/system/Spanner/TransactionTest.php @@ -23,15 +23,22 @@ /** * @group spanner + * @group spanner-transactions */ class TransactionTest extends SpannerTestCase { + const TABLE_NAME = 'Transactions'; + private static $row = []; + private static $tableName; + public static function setUpBeforeClass() { parent::setUpBeforeClass(); + self::$tableName = uniqid(self::TABLE_NAME); + self::$row = [ 'id' => rand(1000,9999), 'name' => uniqid(self::TESTING_PREFIX), @@ -39,6 +46,13 @@ public static function setUpBeforeClass() ]; self::$database->insert(self::TEST_TABLE_NAME, self::$row); + + self::$database->updateDdl( + 'CREATE TABLE '. self::$tableName .' ( + id INT64 NOT NULL, + number INT64 NOT NULL + ) PRIMARY KEY (id)' + )->pollUntilComplete(); } public function testRunTransaction() @@ -61,53 +75,198 @@ public function testRunTransaction() }); } - public function testStrongRead() + /** + * covers 73 + */ + public function testConcurrentTransactionsIncrementValueWithRead() { $db = self::$database; + $db2 = self::$database2; - $snapshot = $db->snapshot([ - 'strong' => true, - 'returnReadTimestamp' => true + $id = $this->randId(); + $db->insert(self::$tableName, [ + 'id' => $id, + 'number' => 0 ]); - list($keySet, $cols) = $this->readArgs(); - $res = $snapshot->read(self::TEST_TABLE_NAME, $keySet, $cols); + $keyset = new KeySet(['keys' => [$id]]); + $columns = ['id','number']; - $row = $res->rows()->current(); + $iteration = 0; + $db->runTransaction(function ($transaction) use ($db2, &$iteration, $keyset, $columns) { + $row = $transaction->read(self::$tableName, $keyset, $columns)->rows()->current(); - $this->assertEquals(self::$row, $row); - $this->assertInstanceOf(Timestamp::class, $snapshot->readTimestamp()); + if ($iteration === 0) { + $db2->runTransaction(function ($t2) use ($keyset, $columns) { + $row = $t2->read(self::$tableName, $keyset, $columns)->rows()->current(); + + $row['number'] = $row['number']+1; + + $t2->update(self::$tableName, $row); + $t2->commit(); + }); + } + + $row['number'] = $row['number']+1; + $iteration++; + + $transaction->update(self::$tableName, $row); + $transaction->commit(); + }); + + $row = $db->execute('SELECT * FROM '. self::$tableName .' WHERE id = @id', [ + 'parameters' => [ + 'id' => $id + ] + ])->rows()->current(); + + $this->assertEquals(2, $row['number']); + } + + /** + * covers 75 + */ + public function testTransactionNoCommit() + { + $db = self::$database; + + $ex = false; + try { + $db->runTransaction(function ($t) { + $t->execute('SELECT * FROM '. self::$tableName); + }); + } catch (\RuntimeException $e) { + $this->assertEquals('Transactions must be rolled back or committed.', $e->getMessage()); + $ex = true; + } + + $this->assertTrue($ex); } - public function testExactTimestampRead() + /** + * covers 76 + */ + public function testAbortedErrorCausesRetry() { $db = self::$database; + $db2 = self::$database2; + + $args = [ + 'id' => $this->randId(), + 'it' => 0, + 'pre' => null, + 'edit' => null, + 'post' => null + ]; + + $db->insert(self::$tableName, [ + 'id' => $args['id'], + 'number' => 0 + ]); + + $db->runTransaction(function ($t) use ($db2, &$args) { + if ($args['it'] === 0) { + $row = $t->execute('SELECT * FROM '. self::$tableName .' WHERE id = @id', [ + 'parameters' => ['id' => $args['id']] + ])->rows()->current(); + + $args['pre'] = $row['number']; - $ts = new Timestamp(new \DateTimeImmutable); + $db2->runTransaction(function ($t2) use (&$args) { + $row = $t2->execute('SELECT * FROM '. self::$tableName .' WHERE id = @id', [ + 'parameters' => ['id' => $args['id']] + ])->rows()->current(); - $row = $db->execute('SELECT * FROM '. self::TEST_TABLE_NAME .' WHERE id = @id', [ - 'parameters' => ['id' => self::$row['id']] + $args['edit'] = $row['number']+1; + $row['number'] = $args['edit']; + $t2->replace(self::$tableName, $row); + $t2->commit(); + }); + } + + $args['it']++; + + $row = $t->execute('SELECT * FROM '. self::$tableName .' WHERE id = @id', [ + 'parameters' => ['id' => $args['id']] + ])->rows()->current(); + + $args['post'] = $row['number']; + $this->assertEquals($row['number'], $args['post']); + $this->assertEquals($args['pre']+1, $row['number']); + + $t->rollback(); + }); + } + + /** + * covers 74 + */ + public function testConcurrentTransactionsIncrementValueWithExecute() + { + $db = self::$database; + $db2 = self::$database2; + + $id = $this->randId(); + $db->insert(self::$tableName, [ + 'id' => $id, + 'number' => 0 + ]); + + $iteration = 0; + $db->runTransaction(function ($transaction) use ($db2, $id, &$iteration) { + $row = $transaction->execute('SELECT * FROM '. self::$tableName .' WHERE id = @id', [ + 'parameters' => [ + 'id' => $id + ] + ])->rows()->current(); + + if ($iteration === 0) { + $db2->runTransaction(function ($t2) use ($id) { + $row = $t2->execute('SELECT * FROM '. self::$tableName .' WHERE id = @id', [ + 'parameters' => [ + 'id' => $id + ] + ])->rows()->current(); + + $row['number'] = $row['number']+1; + + $t2->update(self::$tableName, $row); + $t2->commit(); + }); + } + + $row['number'] = $row['number']+1; + $iteration++; + + $transaction->update(self::$tableName, $row); + $transaction->commit(); + }); + + $row = $db->execute('SELECT * FROM '. self::$tableName .' WHERE id = @id', [ + 'parameters' => [ + 'id' => $id + ] ])->rows()->current(); - $row['name'] = uniqid(self::TESTING_PREFIX); - $db->update(self::TEST_TABLE_NAME, $row); - sleep(10); + $this->assertEquals(2, $row['number']); + } + + public function testStrongRead() + { + $db = self::$database; $snapshot = $db->snapshot([ - 'returnReadTimestamp' => true, - 'readTimestamp' => $ts + 'strong' => true, + 'returnReadTimestamp' => true ]); list($keySet, $cols) = $this->readArgs(); + $res = $snapshot->read(self::TEST_TABLE_NAME, $keySet, $cols); - $res = $snapshot->read(self::TEST_TABLE_NAME, $keySet, $cols)->rows(); - $row = $res->current(); - - $this->assertEquals($ts->get(), $snapshot->readTimestamp()->get()); - $this->assertEquals($row, self::$row); + $row = $res->rows()->current(); - // Reset to previous state. - $db->update(self::TEST_TABLE_NAME, self::$row); + $this->assertEquals(self::$row, $row); + $this->assertInstanceOf(Timestamp::class, $snapshot->readTimestamp()); } private function readArgs() diff --git a/tests/system/Spanner/WriteTest.php b/tests/system/Spanner/WriteTest.php new file mode 100644 index 000000000000..738f536b0bf2 --- /dev/null +++ b/tests/system/Spanner/WriteTest.php @@ -0,0 +1,375 @@ +updateDdl( + 'CREATE TABLE '. self::TABLE_NAME .' ( + id INT64 NOT NULL, + arrayField ARRAY, + arrayBoolField ARRAY, + arrayFloatField ARRAY, + arrayStringField ARRAY, + arrayBytesField ARRAY, + arrayTimestampField ARRAY, + arrayDateField ARRAY, + boolField BOOL, + bytesField BYTES(MAX), + dateField DATE, + floatField FLOAT64, + intField INT64, + stringField STRING(MAX), + timestampField TIMESTAMP + ) PRIMARY KEY (id)' + )->pollUntilComplete(); + } + + public function fieldValueProvider() + { + return [ + [$this->randId(), 'boolField', false], + [$this->randId(), 'boolField', true], + [$this->randId(), 'arrayField', [1,2,3,4,5]], + [$this->randId(), 'dateField', new Date(new \DateTime('1981-01-20'))], + [$this->randId(), 'floatField', 3.1415], + [$this->randId(), 'floatField', INF], + [$this->randId(), 'floatField', -INF], + [$this->randId(), 'intField', 787878787], + [$this->randId(), 'stringField', 'foo bar'], + [$this->randId(), 'timestampField', new Timestamp(new \DateTime)] + ]; + } + + /** + * @dataProvider fieldValueProvider + * covers 78 + * covers 80 + * covers 82 + * covers 84 + * covers 85 + * covers 90 + * covers 92 + */ + public function testWriteAndReadBackValue($id, $field, $value) + { + $db = self::$database; + + $db->insert(self::TABLE_NAME, [ + 'id' => $id, + $field => $value + ]); + + // test result from read + $keyset = new KeySet(['keys' => [$id]]); + $read = $db->read(self::TABLE_NAME, $keyset, [$field]); + $row = $read->rows()->current(); + $this->assertEquals($value, $row[$field]); + + // test result from executeSql + $exec = $db->execute(sprintf('SELECT %s FROM %s WHERE id = @id', $field, self::TABLE_NAME), [ + 'parameters' => [ + 'id' => $id + ] + ]); + + $row = $exec->rows()->current(); + $this->assertEquals($value, $row[$field]); + } + + /** + * covers 87 + */ + public function testWriteAndReadBackBytes() + { + $id = $this->randId(); + $field = 'bytesField'; + $value = new Bytes('hello world'); + + $db = self::$database; + + $db->insert(self::TABLE_NAME, [ + 'id' => $id, + $field => $value + ]); + + $keyset = new KeySet(['keys' => [$id]]); + $read = $db->read(self::TABLE_NAME, $keyset, [$field]); + $row = $read->rows()->current(); + + $this->assertEquals($value->formatAsString(), $row[$field]->formatAsString()); + + $exec = $db->execute(sprintf('SELECT %s FROM %s WHERE id = @id', $field, self::TABLE_NAME), [ + 'parameters' => [ + 'id' => $id + ] + ]); + + $row = $exec->rows()->current(); + $this->assertEquals($value->formatAsString(), $row[$field]->formatAsString()); + } + + /** + * covers 84 + */ + public function testWriteAndReadBackNaN() + { + $id = $this->randId(); + $field = 'floatField'; + $value = NAN; + + $db = self::$database; + + $db->insert(self::TABLE_NAME, [ + 'id' => $id, + $field => $value + ]); + + $keyset = new KeySet(['keys' => [$id]]); + $read = $db->read(self::TABLE_NAME, $keyset, [$field]); + $row = $read->rows()->current(); + + $this->assertTrue(is_nan($row[$field])); + + $exec = $db->execute(sprintf('SELECT %s FROM %s WHERE id = @id', $field, self::TABLE_NAME), [ + 'parameters' => [ + 'id' => $id + ] + ]); + + $row = $exec->rows()->current(); + $this->assertTrue(is_nan($row[$field])); + } + + public function nullFieldValueProvider() + { + $provider = $this->fieldValueProvider(); + $provider[] = [$this->randId(), 'bytesField']; + + return $provider; + } + + /** + * @dataProvider nullFieldValueProvider + * covers 79 + * covers 81 + * covers 83 + * covers 86 + * covers 89 + * covers 91 + * covers 93 + */ + public function testWriteAndReadBackNullValue($id, $field) + { + $db = self::$database; + + $db->insert(self::TABLE_NAME, [ + 'id' => $id, + $field => null + ]); + + // test result from read + $keyset = new KeySet(['keys' => [$id]]); + $read = $db->read(self::TABLE_NAME, $keyset, [$field]); + $row = $read->rows()->current(); + $this->assertNull($row[$field]); + + // test result from executeSql + $exec = $db->execute(sprintf('SELECT %s FROM %s WHERE id = @id', $field, self::TABLE_NAME), [ + 'parameters' => [ + 'id' => $id + ] + ]); + + $row = $exec->rows()->current(); + $this->assertNull($row[$field]); + } + + public function arrayFieldValueProvider() + { + return [ + [$this->randId(), 'arrayField', []], + [$this->randId(), 'arrayField', [1,2,null,4,5]], + [$this->randId(), 'arrayField', null], + [$this->randId(), 'arrayBoolField', [true,false]], + [$this->randId(), 'arrayBoolField', []], + [$this->randId(), 'arrayBoolField', [true, false, null, false]], + [$this->randId(), 'arrayBoolField', null], + [$this->randId(), 'arrayFloatField', [1.1, 1.2, 1.3]], + [$this->randId(), 'arrayFloatField', []], + [$this->randId(), 'arrayFloatField', [1.1, null, 1.3]], + [$this->randId(), 'arrayFloatField', null], + [$this->randId(), 'arrayStringField', ['foo','bar','baz']], + [$this->randId(), 'arrayStringField', []], + [$this->randId(), 'arrayStringField', ['foo',null,'baz']], + [$this->randId(), 'arrayStringField', null], + [$this->randId(), 'arrayBytesField', []], + [$this->randId(), 'arrayBytesField', null], + [$this->randId(), 'arrayTimestampField', []], + [$this->randId(), 'arrayTimestampField', null], + [$this->randId(), 'arrayDateField', []], + [$this->randId(), 'arrayDateField', null], + ]; + } + + /** + * @dataProvider arrayFieldValueProvider + * covers 94 + * covers 95 + * covers 96 + * covers 97 + * covers 98 + * covers 99 + * covers 100 + * covers 101 + * covers 102 + * covers 103 + * covers 104 + * covers 105 + * covers 106 + * covers 107 + * covers 108 + * covers 109 + * covers 110 + * covers 111 + * covers 112 + * covers 113 + * covers 114 + */ + public function testWriteAndReadBackFancyArrayValue($id, $field, $value) + { + $db = self::$database; + + $db->insert(self::TABLE_NAME, [ + 'id' => $id, + $field => $value + ]); + + // test result from read + $keyset = new KeySet(['keys' => [$id]]); + $read = $db->read(self::TABLE_NAME, $keyset, [$field]); + $row = $read->rows()->current(); + + $this->assertEquals($value, $row[$field]); + + // test result from executeSql + $exec = $db->execute(sprintf('SELECT %s FROM %s WHERE id = @id', $field, self::TABLE_NAME), [ + 'parameters' => [ + 'id' => $id + ] + ]); + + $row = $exec->rows()->current(); + + if ($value instanceof Bytes) { + $this->assertEquals($value->formatAsString(), $row[$field]->formatAsString()); + } else { + $this->assertEquals($value, $row[$field]); + } + } + + public function arrayFieldComplexValueProvider() + { + return [ + [$this->randId(), 'arrayBytesField', [new Bytes('foo'),null,new Bytes('baz')]], + [$this->randId(), 'arrayTimestampField', [new Timestamp(new \DateTime),null,new Timestamp(new \DateTime)]], + [$this->randId(), 'arrayDateField', [new Date(new \DateTime),null,new Date(new \DateTime)]], + ]; + } + + /** + * @dataProvider arrayFieldComplexValueProvider + */ + public function testWriteAndReadBackFancyArrayComplexValue($id, $field, $value) + { + $db = self::$database; + + $db->insert(self::TABLE_NAME, [ + 'id' => $id, + $field => $value + ]); + + // test result from read + $keyset = new KeySet(['keys' => [$id]]); + $read = $db->read(self::TABLE_NAME, $keyset, [$field]); + + // test result from executeSql + $exec = $db->execute(sprintf('SELECT %s FROM %s WHERE id = @id', $field, self::TABLE_NAME), [ + 'parameters' => [ + 'id' => $id + ] + ]); + + $row1 = $read->rows()->current(); + $row2 = $exec->rows()->current(); + + foreach ($row2[$field] as $item) { + if (is_null($item)) continue; + + $this->assertInstanceOf(get_class($value[0]), $item); + } + } + + /** + * @expectedException Google\Cloud\Core\Exception\NotFoundException + */ + public function testWriteToNonExistentTableFails() + { + $db = self::$database; + + $db->insert(uniqid(self::TESTING_PREFIX), ['foo' => 'bar']); + } + + /** + * @expectedException Google\Cloud\Core\Exception\NotFoundException + */ + public function testWriteToNonExistentColumnFails() + { + $db = self::$database; + + $db->insert(self::TABLE_NAME, [uniqid(self::TESTING_PREFIX) => 'bar']); + } + + /** + * @expectedException Google\Cloud\Core\Exception\FailedPreconditionException + */ + public function testWriteIncorrectTypeToColumn() + { + $db = self::$database; + + $db->insert(self::TABLE_NAME, [ + 'id' => $this->randId(), + 'boolField' => 'bar' + ]); + } +} diff --git a/tests/unit/Spanner/Connection/GrpcTest.php b/tests/unit/Spanner/Connection/GrpcTest.php index 9b486e493efb..e158a7ebd702 100644 --- a/tests/unit/Spanner/Connection/GrpcTest.php +++ b/tests/unit/Spanner/Connection/GrpcTest.php @@ -154,21 +154,7 @@ public function methodProvider() 'columns' => ['foo'], 'values' => ['bar'] ] - ], - // [ - // 'delete' => [ - // 'table' => $tableName, - // 'keySet' => [ - // 'keys' => ['foo','bar'], - // 'ranges' => [ - // [ - // 'startOpen' => ['foo'], - // 'endClosed' => ['bar'] - // ] - // ] - // ] - // ] - // ] + ] ]; $insertMutationsArr = []; @@ -181,14 +167,6 @@ public function methodProvider() $mutation->setInsert($operation); $insertMutationsArr[] = $mutation; - // $delete = $mutations[1]['delete']; - // $delete['keySet']['keys'] = $this->formatListForApi($delete['keySet']['keys']); - // $operation = (new Mutation\Delete) - // ->deserialize($delete, $codec); - // $mutation = new Mutation; - // $mutation->setDelete($operation); - // $mutationsArr[] = $mutation; - return [ [ 'listInstanceConfigs', @@ -334,11 +312,11 @@ public function methodProvider() [$sessionName, $readOnlyTransactionOptions, []] ], // test insert - [ - 'commit', - ['session' => $sessionName, 'mutations' => $insertMutations], - [$sessionName, $insertMutationsArr, []] - ], + // [ + // 'commit', + // ['session' => $sessionName, 'mutations' => $insertMutations], + // [$sessionName, $insertMutationsArr, []] + // ], // test single-use transaction [ 'commit', diff --git a/tests/unit/Spanner/DatabaseTest.php b/tests/unit/Spanner/DatabaseTest.php index f6dc5692973c..6ea000dd4063 100644 --- a/tests/unit/Spanner/DatabaseTest.php +++ b/tests/unit/Spanner/DatabaseTest.php @@ -369,7 +369,15 @@ public function testRunTransactionRetry() $this->refreshOperation(); - $this->database->runTransaction(function($t){$t->commit();}); + $this->database->runTransaction(function($t) use ($it) { + if ($it > 0) { + $this->assertTrue($t->isRetry()); + } else { + $this->assertFalse($t->isRetry()); + } + + $t->commit(); + }); } /** @@ -395,7 +403,7 @@ public function testRunTransactionAborted() ->shouldBeCalled() ->will(function() use (&$it, $abort) { $it++; - if ($it <= 8) { + if ($it <= Database::MAX_RETRIES+1) { throw $abort; } diff --git a/tests/unit/Spanner/KeyRangeTest.php b/tests/unit/Spanner/KeyRangeTest.php index 8d378ab3df4b..0b8703083455 100644 --- a/tests/unit/Spanner/KeyRangeTest.php +++ b/tests/unit/Spanner/KeyRangeTest.php @@ -58,7 +58,7 @@ public function testGetters() $this->assertEquals(['foo'], $range->start()); $this->assertEquals(['bar'], $range->end()); - $this->assertEquals(['start' => KeyRange::TYPE_CLOSED, 'end' => KeyRange::TYPE_OPEN], $range->types()); + $this->assertEquals(['start' => 'startClosed', 'end' => 'endOpen'], $range->types()); } public function testSetStart() diff --git a/tests/unit/Spanner/TransactionTest.php b/tests/unit/Spanner/TransactionTest.php index cafceb6fc8fa..eabceddf61bb 100644 --- a/tests/unit/Spanner/TransactionTest.php +++ b/tests/unit/Spanner/TransactionTest.php @@ -282,6 +282,25 @@ public function testInvalidReadContext() $this->singleUseTransaction->execute('foo'); } + public function testIsRetryFalse() + { + $this->assertFalse($this->transaction->isRetry()); + } + + public function testIsRetryTrue() + { + $args = [ + $this->operation, + $this->session, + self::TRANSACTION, + true + ]; + + $transaction = \Google\Cloud\Dev\stub(Transaction::class, $args); + + $this->assertTrue($transaction->isRetry()); + } + // ******* // Helpers diff --git a/tests/unit/Spanner/ValueMapperTest.php b/tests/unit/Spanner/ValueMapperTest.php index d5d83eabf9ea..614860f9dde4 100644 --- a/tests/unit/Spanner/ValueMapperTest.php +++ b/tests/unit/Spanner/ValueMapperTest.php @@ -163,8 +163,7 @@ public function testEncodeValuesAsSimpleType() $this->assertEquals((string) $vals['int'], $res[1]); $this->assertEquals((string) $vals['int'], $res[2]); $this->assertEquals($vals['float'], $res[3]); - $this->assertTrue(is_nan($res[4])); - $this->assertEquals(INF, $res[5]); + $this->assertEquals('Infinity', $res[5]); $this->assertEquals($dt->format(Timestamp::FORMAT), $res[6]); $this->assertEquals($dt->format(Date::FORMAT), $res[7]); $this->assertEquals($vals['string'], $res[8]); From 927b91988b1a75fbf7d19c3ee3c206035133065f Mon Sep 17 00:00:00 2001 From: Dave Supplee Date: Fri, 12 May 2017 14:16:26 -0400 Subject: [PATCH 06/11] allow user determined row formatting doc fix address code review move symfony/lock from require to suggest --- composer.json | 11 +- docs/contents/cloud-spanner.json | 3 + src/Spanner/Database.php | 14 +- src/Spanner/Result.php | 117 +++++-- src/Spanner/Session/CacheSessionPool.php | 329 +++++++++++++++--- src/Spanner/ValueMapper.php | 57 ++- tests/snippets/Spanner/ResultTest.php | 20 ++ tests/unit/Spanner/ResultTest.php | 155 ++++++++- tests/unit/Spanner/ResultTestTrait.php | 39 ++- .../Spanner/Session/CacheSessionPoolTest.php | 227 ++++++++++-- tests/unit/Spanner/ValueMapperTest.php | 99 +++++- .../streaming-read-acceptance-test.json | 76 ++++ 12 files changed, 997 insertions(+), 150 deletions(-) diff --git a/composer.json b/composer.json index aaff7d30aaa7..4d56dddc8efe 100644 --- a/composer.json +++ b/composer.json @@ -40,14 +40,13 @@ } ], "require": { - "php": ">=5.5.9", + "php": ">=5.5", "rize/uri-template": "~0.3", "google/auth": "^0.11", "guzzlehttp/guzzle": "^5.3|^6.0", "guzzlehttp/psr7": "^1.2", "monolog/monolog": "~1", - "psr/http-message": "1.0.*", - "symfony/lock": "dev-master" + "psr/http-message": "1.0.*" }, "require-dev": { "phpunit/phpunit": "4.8.*", @@ -58,11 +57,13 @@ "erusev/parsedown": "^1.6", "vierbergenlars/php-semver": "^3.0", "google/proto-client-php": "^0.12", - "google/gax": "^0.8" + "google/gax": "^0.8", + "symfony/lock": "dev-master#1ba6ac9" }, "suggest": { "google/gax": "Required to support gRPC", - "google/proto-client-php": "Required to support gRPC" + "google/proto-client-php": "Required to support gRPC", + "symfony/lock": "Required for the Spanner cached based session pool. Please require the following commit: dev-master#1ba6ac9" }, "autoload": { "psr-4": { diff --git a/docs/contents/cloud-spanner.json b/docs/contents/cloud-spanner.json index f3459b3c5aa0..274b72c15e06 100644 --- a/docs/contents/cloud-spanner.json +++ b/docs/contents/cloud-spanner.json @@ -40,5 +40,8 @@ }, { "title": "Timestamp", "type": "spanner/timestamp" + },{ + "title": "CacheSessionPool", + "type": "spanner/session/cachesessionpool" }] } diff --git a/src/Spanner/Database.php b/src/Spanner/Database.php index d2c5c1340055..331eaa22a30c 100644 --- a/src/Spanner/Database.php +++ b/src/Spanner/Database.php @@ -596,7 +596,7 @@ public function transaction(array $options = []) * If a callable finishes executing without invoking * {@see Google\Cloud\Spanner\Transaction::commit()} or * {@see Google\Cloud\Spanner\Transaction::rollback()}, the transaction will - * automatically be rolled back and `RuntimeException` thrown. + * automatically be rolled back and `\RuntimeException` thrown. * * Example: * ``` @@ -643,7 +643,7 @@ public function transaction(array $options = []) * `false`. * } * @return mixed The return value of `$operation`. - * @throws RuntimeException + * @throws \RuntimeException */ public function runTransaction(callable $operation, array $options = []) { @@ -1274,7 +1274,7 @@ public function sessionPool() /** * Closes the database connection by returning the active session back to - * the session pool or by deleting the session if there is no pool + * the session pool queue or by deleting the session if there is no pool * associated. * * It is highly important to ensure this is called as it is not always safe @@ -1359,9 +1359,13 @@ public function session($name) */ public function identity() { + $databaseParts = explode('/', $this->name); + $instanceParts = explode('/', $this->instance->name()); + return [ - 'database' => $this->name, - 'instance' => $this->instance->name(), + 'projectId' => $this->projectId, + 'database' => end($databaseParts), + 'instance' => end($instanceParts), ]; } diff --git a/src/Spanner/Result.php b/src/Spanner/Result.php index 3e68ce94def5..25a9a7d7dfdb 100644 --- a/src/Spanner/Result.php +++ b/src/Spanner/Result.php @@ -22,7 +22,6 @@ use Google\Cloud\Core\ExponentialBackoff; use Google\Cloud\Spanner\Session\Session; use Google\Cloud\Spanner\Session\SessionPoolInterface; -use Google\Cloud\Spanner\Timestamp; /** * Represent a Cloud Spanner lookup result (either read or executeSql). @@ -43,6 +42,10 @@ class Result implements \IteratorAggregate { const BUFFER_RESULT_LIMIT = 10; + const RETURN_NAME_VALUE_PAIR = 'nameValuePair'; + const RETURN_ASSOCIATIVE = 'associative'; + const RETURN_ZERO_INDEXED = 'zeroIndexed'; + /** * @var array */ @@ -51,7 +54,12 @@ class Result implements \IteratorAggregate /** * @var int */ - private $columnCount; + private $columnCount = 0; + + /** + * @var array|null + */ + private $columnNames; /** * @var ValueMapper @@ -138,9 +146,23 @@ public function __construct( * $rows = $result->rows(); * ``` * + * @param string $format Determines the format in which rows are returned. + * `Result::RETURN_NAME_VALUE_PAIR` returns items as a + * multi-dimensional array containing a name and a value key. + * Ex: `[0 => ['name' => 'column1', 'value' => 'my_value']]`. + * `Result::RETURN_ASSOCIATIVE` returns items as an associative array + * with the column name as the key. Please note with this option, if + * duplicate column names are present a `\RuntimeException` will be + * thrown. `Result::RETURN_ZERO_INDEXED` returns items as a 0 indexed + * array, with the key representing the column number as found by + * executing {@see Google\Cloud\Spanner\Result::columns()}. Ex: + * `[0 => 'my_value']`. **Defaults to** `Result::RETURN_ASSOCIATIVE`. * @return \Generator + * @throws \InvalidArgumentException When an invalid format is provided. + * @throws \RuntimeException When duplicate column names exist with a + * selected format of `Result::RETURN_ASSOCIATIVE`. */ - public function rows() + public function rows($format = self::RETURN_ASSOCIATIVE) { $bufferedResults = []; $call = $this->call; @@ -151,7 +173,7 @@ public function rows() try { $result = $generator->current(); $bufferedResults[] = $result; - $this->setResultData($result); + $this->setResultData($result, $format); if (!isset($result['values'])) { return; @@ -161,7 +183,7 @@ public function rows() list($yieldableRows, $chunkedResult) = $this->parseRowsFromBufferedResults($bufferedResults); foreach ($yieldableRows as $row) { - yield $this->mapper->decodeValues($this->columns, $row); + yield $this->mapper->decodeValues($this->columns, $row, $format); } // Now that we've yielded all available rows, flush the buffer. @@ -179,21 +201,21 @@ public function rows() } $generator->next(); - } catch (\Exception $ex) { - if ($shouldRetry && $ex->getCode() === Grpc\STATUS_UNAVAILABLE) { - $backoff = new ExponentialBackoff($this->retries, function (\Exception $ex) { - return $ex->getCode() === Grpc\STATUS_UNAVAILABLE - ? true - : false; - }); - - // Attempt to resume using our last stored resume token. If we - // successfully resume, flush the buffer. - $generator = $backoff->execute($call, [$this->resumeToken]); - $bufferedResults = []; + } catch (ServiceException $ex) { + if (!$shouldRetry || $ex->getCode() !== Grpc\STATUS_UNAVAILABLE) { + throw $ex; } - throw $ex; + $backoff = new ExponentialBackoff($this->retries, function (ServiceException $ex) { + return $ex->getCode() === Grpc\STATUS_UNAVAILABLE + ? true + : false; + }); + + // Attempt to resume using our last stored resume token. If we + // successfully resume, flush the buffer. + $generator = $backoff->execute($call, [$this->resumeToken]); + $bufferedResults = []; } } @@ -202,11 +224,28 @@ public function rows() list($yieldableRows, $chunkedResult) = $this->parseRowsFromBufferedResults($bufferedResults); foreach ($yieldableRows as $row) { - yield $this->mapper->decodeValues($this->columns, $row); + yield $this->mapper->decodeValues($this->columns, $row, $format); } } } + /** + * Return column names. + * + * Will be populated once the result set is iterated upon. + * + * Example: + * ``` + * $columns = $result->columns(); + * ``` + * + * @return array|null + */ + public function columns() + { + return $this->columnNames; + } + /** * Return result metadata. * @@ -226,6 +265,21 @@ public function metadata() return $this->metadata; } + /** + * Return the session associated with the result stream. + * + * Example: + * ``` + * $session = $result->session(); + * ``` + * + * @return Session + */ + public function session() + { + return $this->session; + } + /** * Get the query plan and execution statistics for the query that produced * this result set. @@ -338,8 +392,10 @@ private function parseRowsFromBufferedResults(array $bufferedResults) /** * @param array $result + * @param string $format + * @throws \RuntimeException */ - private function setResultData(array $result) + private function setResultData(array $result, $format) { $this->stats = isset($result['stats']) ? $result['stats'] @@ -350,9 +406,28 @@ private function setResultData(array $result) } if (isset($result['metadata'])) { + $this->columnNames = []; + $this->columns = []; + $this->columnCount = 0; $this->metadata = $result['metadata']; $this->columns = $result['metadata']['rowType']['fields']; - $this->columnCount = count($this->columns); + + foreach ($this->columns as $key => $column) { + $this->columnNames[] = isset($column['name']) + ? $column['name'] + : $key; + $this->columnCount++; + } + + if ($format === self::RETURN_ASSOCIATIVE + && $this->columnCount !== count(array_unique($this->columnNames)) + ) { + throw new \RuntimeException( + 'Duplicate column names are not supported when returning' . + ' rows in the associative format. Please consider aliasing' . + ' your column names.' + ); + } } if (isset($result['metadata']['transaction']['id'])) { diff --git a/src/Spanner/Session/CacheSessionPool.php b/src/Spanner/Session/CacheSessionPool.php index b67737eec5b5..56e369955506 100644 --- a/src/Spanner/Session/CacheSessionPool.php +++ b/src/Spanner/Session/CacheSessionPool.php @@ -17,6 +17,7 @@ namespace Google\Cloud\Spanner\Session; +use Google\Cloud\Core\Exception\NotFoundException; use Google\Cloud\Core\Lock\LockInterface; use Google\Cloud\Core\Lock\SymfonyLockAdapter; use Google\Cloud\Spanner\Database; @@ -28,19 +29,47 @@ * This session pool implementation accepts a PSR-6 compatible cache * implementation and utilizes it to store sessions between requests. * - * Please note that if you configure a high minimum session value the first - * request and any after a period of inactivity greater than an hour (the point - * at which sessions will expire) will have an increased amount of latency. This - * is due to the pool attempting to create as many sessions as needed to fill - * itself to match the minimum value. + * Please note that when + * {@see Google\Cloud\Spanner\Session\CacheSessionPool::acquire()} is called at + * most only a single session is created. Due to this, it is possible to sit + * under the minimum session value declared when constructing this instance. In + * order to have the pool match the minimum session value please use the + * {@see Google\Cloud\Spanner\Session\CacheSessionPool::warmup()} method. This + * will create as many sessions as needed to match the minimum value, and is the + * recommended way to bootstrap the session pool. * - * For this reason, it is highly recommended to configure a script to make an - * initial request to warm up the pool and manage subsequent requests during - * off-peak hours to keep the pool active. + * Sessions are created on demand up to the maximum session value set during + * instantiation of the pool. After peak usage hours, you may find that more + * sessions are available than your demand may require. It is important to make + * sure the number of active sessions managed by the Spanner backend is kept + * as minimal as possible. In order to help maintain this balance, please use + * the {@see Google\Cloud\Spanner\Session\CacheSessionPool::downsize()} method + * on an interval that matches when you expect to see a decrease in traffic. + * This will help ensure you never run into issues where the Spanner backend is + * locked up after having met the maximum number of sessions assigned per node + * (The current maximum sessions per node is 10k). + * + * Additionally, when expecting a long period of inactivity (such as a + * maintenance window), please make sure to call + * {@see Google\Cloud\Spanner\Session\CacheSessionPool::clear()} in order to + * delete any active sessions. * * Please note: While required for the session pool, a PSR-6 implementation is * not included in this library. It will be neccesary to include a separate - * dependency to fulfill this requirement. + * dependency to fulfill this requirement. The below example makes use of + * [Symfony's Cache Component](https://github.com/symfony/cache). For more + * implementations please see the + * [Packagist PHP Package Repository](https://packagist.org/providers/psr/cache-implementation). + * + * Furthermore, [Symfony's Lock Component](https://github.com/symfony/lock) is + * also required to be installed as a separate dependency. In our current alpha + * state with Spanner we are relying on the following dev commit: + * + * `composer require symfony/lock:dev-master#1ba6ac9` + * + * As development continues, this dependency on a dev-master branch will be + * discontinued. Please also note, since this is a dev-master dependency it may + * require modifications to your composer minimum-stability settings. * * Example: * ``` @@ -59,7 +88,10 @@ */ class CacheSessionPool implements SessionPoolInterface { - const CACHE_KEY_TEMPLATE = 'cache-session-pool.%s.%s'; + const CACHE_KEY_TEMPLATE = 'cache-session-pool.%s.%s.%s'; + + const DURATION_TWENTY_MINUTES = 1200; + const DURATION_ONE_MINUTE = 60; /** * @var array @@ -99,7 +131,7 @@ class CacheSessionPool implements SessionPoolInterface * Configuration Options. * * @type int $maxSessions The maximum number of sessions to store in the - * pool. **Defaults to** PHP_INT_MAX. + * pool. **Defaults to** `500`. * @type int $minSessions The minimum number of sessions to store in the * pool. **Defaults to** `1`. * @type bool $shouldWaitForSession If the pool is full, whether to block @@ -142,7 +174,7 @@ public function acquire($context = SessionPoolInterface::CONTEXT_READ) list($session, $toCreate) = $this->config['lock']->synchronize(function () { $toCreate = []; $session = null; - $shouldSave = false; + $shouldSave = true; $item = $this->cacheItemPool->getItem($this->cacheKey); $data = (array) $item->get() ?: $this->initialize(); @@ -152,51 +184,57 @@ public function acquire($context = SessionPoolInterface::CONTEXT_READ) // for more. if ($data['queue']) { $session = $this->getSession($data); - $shouldSave = true; } elseif ($this->config['maxSessions'] <= $this->getSessionCount($data)) { $this->purgeOrphanedInUseSessions($data); $this->purgeOrphanedToCreateItems($data); - $shouldSave = true; + $session = $this->getSession($data); } - $toCreate = $this->buildToCreateList($data, is_array($session)); - $data['toCreate'] += $toCreate; + if (!$session) { + $count = $this->getSessionCount($data); + + if ($count < $this->config['maxSessions']) { + $toCreate = $this->buildToCreateList(1); + $data['toCreate'] += $toCreate; + } else { + $shouldSave = false; + } + } - if ($shouldSave || $toCreate) { + if ($shouldSave) { $this->cacheItemPool->save($item->set($data)); } return [$session, $toCreate]; }); - // Create sessions if needed. + // Create a session if needed. if ($toCreate) { $createdSessions = []; $exception = null; try { $createdSessions = $this->createSessions(count($toCreate)); - } catch (\Exception $ex) { - $exception = $ex; + } catch (\Exception $exception) { } $session = $this->config['lock']->synchronize(function () use ( - $session, $toCreate, $createdSessions, $exception ) { + $session = null; $item = $this->cacheItemPool->getItem($this->cacheKey); $data = $item->get(); $data['queue'] = array_merge($data['queue'], $createdSessions); - // Now that we've created the sessions, we can remove them from + // Now that we've created the session, we can remove it from // the list of intent. foreach ($toCreate as $id => $time) { unset($data['toCreate'][$id]); } - if (!$session && !$exception) { + if (!$exception) { $session = array_shift($data['queue']); $data['inUse'][$session['name']] = $session + [ @@ -252,12 +290,203 @@ public function release(Session $session) } /** - * Clear the session pool. Please note that this simply removes sessions - * data from the cache and does not delete the sessions themselves. + * Keeps a checked out session alive. + * + * In use sessions that have not had their `lastActive` time updated + * in the last 20 minutes will be considered eligible to be moved back into + * the queue if the max sessions value has been met. In order to work around + * this when performing a large streaming execute or read call please make + * sure to call this method roughly every 15 minutes between reading rows + * to keep your session active. + * + * @param Session $session The session to keep alive. + */ + public function keepAlive(Session $session) + { + $this->config['lock']->synchronize(function () use ($session) { + $item = $this->cacheItemPool->getItem($this->cacheKey); + $data = $item->get(); + $data['inUse'][$session->name()]['lastActive'] = $this->time(); + + $this->cacheItemPool->save($item->set($data)); + }); + } + + /** + * Downsizes the queue of available sessions by the given percentage. This is + * relative to the minimum sessions value. For example: Assuming a full + * queue, with maximum sessions of 10 and a minimum of 2, downsizing by 50% + * would leave 6 sessions in the queue. The count of items to be deleted will + * be rounded up in the case of a fraction. + * + * A session may be removed from the cache, but still tracked as active by + * the Spanner backend if a delete operation failed. To ensure you do not + * exceed the maximum number of sessions available per node, please be sure + * to check the return value of this method to be certain all sessions have + * been deleted. + * + * Please note this method will attempt to synchronously delete sessions and + * will block until complete. + * + * @param int $percent The percentage to downsize the pool by. Must be + * between 1 and 100. + * @return array An associative array containing a key `deleted` which holds + * an integer value representing the number of queued sessions + * deleted on the backend and a key `failed` which holds a list of + * queued {@see Google\Cloud\Spanner\Session\Session} objects which + * failed to delete. + * @throws \InvaldArgumentException + */ + public function downsize($percent) + { + if ($percent < 1 || 100 < $percent) { + throw new \InvalidArgumentException('The provided percent must be between 1 and 100.'); + } + + $failed = []; + $toDelete = $this->config['lock']->synchronize(function () use ($percent) { + $item = $this->cacheItemPool->getItem($this->cacheKey); + $data = (array) $item->get() ?: $this->initialize(); + $toDelete = []; + $queueCount = count($data['queue']); + $availableCount = max($queueCount - $this->config['minSessions'], 0); + $countToDelete = ceil($availableCount * ($percent * 0.01)); + + if ($countToDelete) { + $toDelete = array_splice($data['queue'], (int) -$countToDelete); + } + + $this->cacheItemPool->save($item->set($data)); + return $toDelete; + }); + + foreach ($toDelete as $sessionData) { + $session = $this->database->session($sessionData['name']); + + try { + $session->delete(); + } catch (\Exception $ex) { + if ($ex instanceof NotFoundException) { + continue; + } + + $failed[] = $session; + } + } + + return [ + 'deleted' => count($toDelete) - count($failed), + 'failed' => $failed + ]; + } + + /** + * Create enough sessions to meet the minimum session constraint. + * + * @return int The number of sessions created and added to the queue. + */ + public function warmup() + { + $toCreate = $this->config['lock']->synchronize(function () { + $item = $this->cacheItemPool->getItem($this->cacheKey); + $data = (array) $item->get() ?: $this->initialize(); + $count = $this->getSessionCount($data); + $toCreate = []; + + if ($count < $this->config['minSessions']) { + $toCreate = $this->buildToCreateList($this->config['minSessions'] - $count); + $data['toCreate'] += $toCreate; + $this->cacheItemPool->save($item->set($data)); + } + + return $toCreate; + }); + + if (!$toCreate) { + return 0; + } + + $createdSessions = []; + $exception = null; + + try { + $createdSessions = $this->createSessions(count($toCreate)); + } catch (\Exception $exception) { + } + + $this->config['lock']->synchronize(function () use ($toCreate, $createdSessions) { + $item = $this->cacheItemPool->getItem($this->cacheKey); + $data = $item->get(); + $data['queue'] = array_merge($data['queue'], $createdSessions); + + // Now that we've created the sessions, we can remove them from + // the list of intent. + foreach ($toCreate as $id => $time) { + unset($data['toCreate'][$id]); + } + + $this->cacheItemPool->save($item->set($data)); + }); + + if ($exception) { + throw $exception; + } + + return count($toCreate); + } + + /** + * Clear the cache and attempt to delete all sessions in the pool. + * + * A session may be removed from the cache, but still tracked as active by + * the Spanner backend if a delete operation failed. To ensure you do not + * exceed the maximum number of sessions available per node, please be sure + * to check the return value of this method to be certain all sessions have + * been deleted. + * + * Please note this method will attempt to synchronously delete sessions and + * will block until complete. + * + * @return array An array containing a list of + * {@see Google\Cloud\Spanner\Session\Session} objects which failed + * to delete. */ public function clear() { - $this->cacheItemPool->clear(); + $failed = []; + $sessions = $this->config['lock']->synchronize(function () { + $sessions = []; + $item = $this->cacheItemPool->getItem($this->cacheKey); + $data = (array) $item->get() ?: $this->initialize(); + + foreach ($data['queue'] as $session) { + $sessions[] = $session['name']; + } + + foreach ($data['inUse'] as $session) { + $sessions[] = $session['name']; + } + + $this->cacheItemPool->clear(); + + return $sessions; + }); + + foreach ($sessions as $sessionName) { + $session = $this->database->session($sessionName); + + try { + $session->delete(); + } catch (\Exception $ex) { + if ($ex instanceof NotFoundException) { + continue; + } + + $failed[] = $session; + } + } + + return $failed; } /** @@ -269,7 +498,12 @@ public function setDatabase(Database $database) { $this->database = $database; $identity = $database->identity(); - $this->cacheKey = sprintf(self::CACHE_KEY_TEMPLATE, $identity['instance'], $identity['database']); + $this->cacheKey = sprintf( + self::CACHE_KEY_TEMPLATE, + $identity['projectId'], + $identity['instance'], + $identity['database'] + ); } /** @@ -296,22 +530,13 @@ protected function time() * Builds out a list of timestamps indicating the start time of the intent * to create a session. * - * @param array $data - * @param bool $hasSession + * @param int $number * @return array */ - private function buildToCreateList(array $data, $hasSession) + private function buildToCreateList($number) { - $number = 0; $toCreate = []; $time = $this->time(); - $count = $this->getSessionCount($data); - - if ($count < $this->config['minSessions']) { - $number = $this->config['minSessions'] - $count; - } elseif (!$hasSession && !$data['queue'] && $count < $this->config['maxSessions']) { - $number++; - } for ($i = 0; $i < $number; $i++) { $toCreate[uniqid($time . '_')] = $time; @@ -329,21 +554,29 @@ private function buildToCreateList(array $data, $hasSession) private function purgeOrphanedToCreateItems(array &$data) { foreach ($data['toCreate'] as $key => $timestamp) { - if ($timestamp + 1200 < $this->time()) { + $time = $this->time(); + + if ($timestamp + self::DURATION_TWENTY_MINUTES < $this->time()) { unset($data['toCreate'][$key]); } } } /** - * Purge any in use sessions that have been inactive for 20 minutes or more. + * Purges in use sessions. If a session was last active an hour ago, we + * assume it is expired and remove it from the pool. If last active 20 + * minutes ago, we attempt to return the session back to the queue. * * @param array $data */ private function purgeOrphanedInUseSessions(array &$data) { foreach ($data['inUse'] as $key => $session) { - if ($session['lastActive'] + 1200 < $this->time()) { + if ($session['lastActive'] + SessionPoolInterface::SESSION_EXPIRATION_SECONDS < $this->time()) { + unset($data['inUse'][$key]); + } elseif ($session['lastActive'] + self::DURATION_TWENTY_MINUTES < $this->time()) { + unset($session['lastActive']); + array_push($data['queue'], $session); unset($data['inUse'][$key]); } } @@ -392,7 +625,7 @@ private function getSession(array &$data) $session = array_shift($data['queue']); if ($session) { - if ($session['expiration'] - 60 < $this->time()) { + if ($session['expiration'] - self::DURATION_ONE_MINUTE < $this->time()) { return $this->getSession($data); } @@ -510,9 +743,21 @@ private function waitForNextAvailableSession() * Get the default lock. * * @return LockInterface + * @throws \RunTimeException */ private function getDefaultLock() { + if (!class_exists(FlockStore::class)) { + throw new \RuntimeException( + 'The symfony/lock component must be installed in order for ' . + 'a default lock to be assumed. Please run the following from ' . + 'the command line: composer require symfony/lock:dev-master#1ba6ac9. ' . + 'Please note, since this is a dev-master dependency it may ' . + 'require modifications to your composer minimum-stability ' . + 'settings.' + ); + } + $store = new FlockStore(sys_get_temp_dir()); return new SymfonyLockAdapter( diff --git a/src/Spanner/ValueMapper.php b/src/Spanner/ValueMapper.php index f6a2a238e53e..ca3d57c54998 100644 --- a/src/Spanner/ValueMapper.php +++ b/src/Spanner/ValueMapper.php @@ -144,27 +144,43 @@ public function encodeValuesAsSimpleType(array $values) * * @param array $columns The list of columns. * @param array $row The row data. + * @param string $format The format in which to return the rows. * @return array The decoded row data. + * @throws \InvalidArgumentException */ - public function decodeValues(array $columns, array $row) + public function decodeValues(array $columns, array $row, $format) { - $cols = []; - $types = []; - - foreach ($columns as $index => $column) { - $cols[] = (isset($column['name'])) - ? $column['name'] - : $index; - $types[] = $column['type']; - } + switch ($format) { + case Result::RETURN_NAME_VALUE_PAIR: + foreach ($row as $index => $value) { + $row[$index] = [ + 'name' => $this->getColumnName($columns, $index), + 'value' => $this->decodeValue($value, $columns[$index]['type']) + ]; + } - $res = []; - foreach ($row as $index => $value) { - $i = $cols[$index]; - $res[$i] = $this->decodeValue($value, $types[$index]); - } + return $row; - return $res; + case Result::RETURN_ASSOCIATIVE: + foreach ($row as $index => $value) { + unset($row[$index]); + $row[$this->getColumnName($columns, $index)] = $this->decodeValue( + $value, + $columns[$index]['type'] + ); + } + + return $row; + + case Result::RETURN_ZERO_INDEXED: + foreach ($row as $index => $value) { + $row[$index] = $this->decodeValue($value, $columns[$index]['type']); + } + + return $row; + default: + throw new \InvalidArgumentException('Invalid format provided.'); + } } /** @@ -226,7 +242,7 @@ private function decodeValue($value, array $type) break; case self::TYPE_STRUCT: - $value = $this->decodeValues($type['structType']['fields'], $value); + $value = $this->decodeValues($type['structType']['fields'], $value, Result::RETURN_ASSOCIATIVE); break; case self::TYPE_FLOAT64: @@ -397,4 +413,11 @@ private function typeObject($type, array $nestedDefinition = [], $nestedDefiniti $nestedDefinitionType => $nestedDefinition ]); } + + private function getColumnName($columns, $index) + { + return isset($columns[$index]['name']) + ? $columns[$index]['name'] + : $index; + } } diff --git a/tests/snippets/Spanner/ResultTest.php b/tests/snippets/Spanner/ResultTest.php index 766525d0c176..acff7135e19e 100644 --- a/tests/snippets/Spanner/ResultTest.php +++ b/tests/snippets/Spanner/ResultTest.php @@ -44,6 +44,10 @@ public function setUp() ->willReturn($this->resultGenerator()); $result->metadata() ->willReturn([]); + $result->columns() + ->willReturn([]); + $result->session() + ->willReturn($this->prophesize(Session::class)->reveal()); $result->snapshot() ->willReturn($this->prophesize(Snapshot::class)->reveal()); $result->transaction() @@ -77,6 +81,14 @@ public function testRows() $this->assertInstanceOf(\Generator::class, $res->returnVal()); } + public function testColumns() + { + $snippet = $this->snippetFromMethod(Result::class, 'columns'); + $snippet->addLocal('result', $this->result); + $res = $snippet->invoke('columns'); + $this->assertInternalType('array', $res->returnVal()); + } + public function testMetadata() { $snippet = $this->snippetFromMethod(Result::class, 'metadata'); @@ -85,6 +97,14 @@ public function testMetadata() $this->assertInternalType('array', $res->returnVal()); } + public function testSession() + { + $snippet = $this->snippetFromMethod(Result::class, 'session'); + $snippet->addLocal('result', $this->result); + $res = $snippet->invoke('session'); + $this->assertInstanceOf(Session::class, $res->returnVal()); + } + public function testStats() { $snippet = $this->snippetFromMethod(Result::class, 'stats'); diff --git a/tests/unit/Spanner/ResultTest.php b/tests/unit/Spanner/ResultTest.php index 173468c00ada..e62aaab4ee8d 100644 --- a/tests/unit/Spanner/ResultTest.php +++ b/tests/unit/Spanner/ResultTest.php @@ -17,8 +17,13 @@ namespace Google\Cloud\Tests\Unit\Spanner; -use Google\Cloud\Spanner\Snapshot; +use Google\Cloud\Core\Exception\ServiceException; use Google\Cloud\Spanner\Transaction; +use Google\Cloud\Spanner\Result; +use Google\Cloud\Spanner\Session\Session; +use Google\Cloud\Spanner\Snapshot; +use Google\Cloud\Spanner\ValueMapper; +use Prophecy\Argument; /** * @group spanner @@ -27,12 +32,30 @@ class ResultTest extends \PHPUnit_Framework_TestCase { use ResultTestTrait; + private $metadata = [ + 'rowType' => [ + 'fields' => [ + [ + 'name' => 'f1', + 'type' => 6 + ] + ] + ] + ]; + + public function setUp() + { + if (!extension_loaded('grpc')) { + $this->markTestSkipped('Must have the grpc extension installed to run this test.'); + } + } + /** * @dataProvider streamingDataProvider */ public function testRows($chunks, $expectedValues) { - $result = iterator_to_array($this->getResultClass($chunks)); + $result = iterator_to_array($this->getResultClass($chunks)->rows()); $this->assertEquals($expectedValues, $result); } @@ -45,6 +68,86 @@ public function testIterator() $this->assertEquals($fixture['result']['value'], $result); } + public function testResumesBrokenStream() + { + $timesCalled = 0; + $chunks = [ + [ + 'metadata' => $this->metadata, + 'values' => ['a'] + ], + [ + 'values' => ['b'], + 'resumeToken' => 'abc' + ], + [ + 'values' => ['c'] + ] + ]; + + $result = $this->getResultClass( + null, + 'r', + null, + function () use ($chunks, &$timesCalled) { + $timesCalled++; + + foreach ($chunks as $key => $chunk) { + if ($timesCalled === 1 && $key === 2) { + throw new ServiceException('Unavailable', 14); + } + yield $chunk; + } + + } + ); + iterator_to_array($result->rows()); + $this->assertEquals(2, $timesCalled); + } + + /** + * @expectedException Google\Cloud\Core\Exception\ServiceException + */ + public function testThrowsExceptionWhenCannotRetry() + { + $chunks = [ + [ + 'metadata' => $this->metadata, + 'values' => ['a'] + ], + [ + 'values' => ['b'] + ] + ]; + + $result = $this->getResultClass( + null, + 'r', + null, + function () use ($chunks) { + foreach ($chunks as $key => $chunk) { + if ($key === 1) { + throw new ServiceException('Should not retry this.'); + } + yield $chunk; + } + + } + ); + iterator_to_array($result->rows()); + } + + public function testColumns() + { + $fixture = $this->getStreamingDataFixture()['tests'][0]; + $result = $this->getResultClass($fixture['chunks']); + $expectedColumnNames = ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7']; + + $this->assertNull($result->columns()); + $result->rows()->next(); + $this->assertEquals($expectedColumnNames, $result->columns()); + } + public function testMetadata() { $fixture = $this->getStreamingDataFixture()['tests'][0]; @@ -56,6 +159,14 @@ public function testMetadata() $this->assertEquals($expectedMetadata, $result->metadata()); } + public function testSession() + { + $fixture = $this->getStreamingDataFixture()['tests'][0]; + $result = $this->getResultClass($fixture['chunks']); + + $this->assertInstanceOf(Session::class, $result->session()); + } + public function testStats() { $fixture = $this->getStreamingDataFixture()['tests'][1]; @@ -86,4 +197,44 @@ public function testSnapshot() $result->rows()->next(); $this->assertInstanceOf(Snapshot::class, $result->snapshot()); } + + public function testUsesCorrectDefaultFormatOption() + { + $mapper = $this->prophesize(ValueMapper::class); + $mapper->decodeValues( + Argument::any(), + Argument::any(), + 'nameValuePair' + ); + $result = $this->getResultClass([], 'r', $mapper->reveal()); + + $rows = $result->rows(); + $rows->current(); + } + + /** + * @dataProvider formatProvider + */ + public function testRecievesCorrectFormatOption($format) + { + $mapper = $this->prophesize(ValueMapper::class); + $mapper->decodeValues( + Argument::any(), + Argument::any(), + $format + ); + $result = $this->getResultClass([], 'r', $mapper->reveal()); + + $rows = $result->rows($format); + $rows->current(); + } + + public function formatProvider() + { + return [ + ['nameValuePair'], + ['associative'], + ['zeroIndexed'] + ]; + } } diff --git a/tests/unit/Spanner/ResultTestTrait.php b/tests/unit/Spanner/ResultTestTrait.php index b40d1d270cdc..e260f110c8d5 100644 --- a/tests/unit/Spanner/ResultTestTrait.php +++ b/tests/unit/Spanner/ResultTestTrait.php @@ -42,19 +42,34 @@ public function streamingDataProviderFirstChunk() } } - private function getResultClass($chunks, $context = 'r') - { + private function getResultClass( + $chunks = null, + $context = 'r', + $mapper = null, + $call = null + ) { $operation = $this->prophesize(Operation::class); $session = $this->prophesize(Session::class)->reveal(); - $mapper = $this->prophesize(ValueMapper::class); $transaction = $this->prophesize(Transaction::class); $snapshot = $this->prophesize(Snapshot::class); - $mapper->decodeValues( - Argument::any(), - Argument::any() - )->will(function ($args) { - return $args[1]; - }); + + if (!$mapper) { + $mapper = $this->prophesize(ValueMapper::class); + $mapper->decodeValues( + Argument::any(), + Argument::any(), + Argument::any() + )->will(function ($args) { + return $args[1]; + }); + $mapper = $mapper->reveal(); + } + + if (!$call) { + $call = function () use ($chunks) { + return $this->resultGenerator($chunks); + }; + } if ($context === 'r') { $operation->createSnapshot( @@ -71,11 +86,9 @@ private function getResultClass($chunks, $context = 'r') return new Result( $operation->reveal(), $session, - function () use ($chunks) { - return $this->resultGenerator($chunks); - }, + $call, $context, - $mapper->reveal() + $mapper ); } diff --git a/tests/unit/Spanner/Session/CacheSessionPoolTest.php b/tests/unit/Spanner/Session/CacheSessionPoolTest.php index 8391af10b88e..0083162dc72c 100644 --- a/tests/unit/Spanner/Session/CacheSessionPoolTest.php +++ b/tests/unit/Spanner/Session/CacheSessionPoolTest.php @@ -31,7 +31,8 @@ */ class CacheSessionPoolTest extends \PHPUnit_Framework_TestCase { - const CACHE_KEY_TEMPLATE = 'cache-session-pool.%s.%s'; + const CACHE_KEY_TEMPLATE = 'cache-session-pool.%s.%s.%s'; + const PROJECT_ID = 'project'; const DATABASE_NAME = 'database'; const INSTANCE_NAME = 'instance'; @@ -135,7 +136,7 @@ public function testAcquireRemovesToCreateItemsIfCreateCallFails() $actualItemPool = $pool->cacheItemPool(); $actualCacheData = $actualItemPool->getItem( - sprintf(self::CACHE_KEY_TEMPLATE, self::INSTANCE_NAME, self::DATABASE_NAME) + sprintf(self::CACHE_KEY_TEMPLATE, self::PROJECT_ID, self::INSTANCE_NAME, self::DATABASE_NAME) )->get(); $this->assertEmpty($actualCacheData['toCreate']); @@ -175,19 +176,165 @@ public function testRelease() $pool->release($session->reveal()); $actualItemPool = $pool->cacheItemPool(); $actualCacheData = $actualItemPool->getItem( - sprintf(self::CACHE_KEY_TEMPLATE, self::INSTANCE_NAME, self::DATABASE_NAME) + sprintf(self::CACHE_KEY_TEMPLATE, self::PROJECT_ID, self::INSTANCE_NAME, self::DATABASE_NAME) )->get(); $this->assertEquals($expectedCacheData, $actualCacheData); } + public function testKeepAlive() + { + $sessionName = 'alreadyCheckedOut'; + $lastActiveOriginal = 1000; + $session = $this->prophesize(Session::class); + $session->name() + ->willReturn($sessionName); + $pool = new CacheSessionPoolStub($this->getCacheItemPool([ + 'queue' => [], + 'inUse' => [ + $sessionName => [ + 'name' => $sessionName, + 'expiration' => $this->time + 3600, + 'lastActive' => $lastActiveOriginal + ] + ], + 'toCreate' => [] + ]), [], $this->time); + $pool->setDatabase($this->getDatabase()); + $actualItemPool = $pool->cacheItemPool(); + $actualCacheData = $actualItemPool->getItem( + sprintf(self::CACHE_KEY_TEMPLATE, self::PROJECT_ID, self::INSTANCE_NAME, self::DATABASE_NAME) + )->get(); + + $this->assertEquals($lastActiveOriginal, $actualCacheData['inUse'][$sessionName]['lastActive']); + + $pool->keepAlive($session->reveal()); + $actualCacheData = $actualItemPool->getItem( + sprintf(self::CACHE_KEY_TEMPLATE, self::PROJECT_ID, self::INSTANCE_NAME, self::DATABASE_NAME) + )->get(); + + $this->assertEquals($this->time, $actualCacheData['inUse'][$sessionName]['lastActive']); + } + + /** + * @dataProvider downsizeDataProvider + */ + public function testDownsizeDeletes($percent, $expectedDeleteCount) + { + $time = time() + 3600; + $pool = new CacheSessionPoolStub($this->getCacheItemPool([ + 'queue' => [ + [ + 'name' => 'session0', + 'expiration' => $time + ], + [ + 'name' => 'session1', + 'expiration' => $time + ], + [ + 'name' => 'session2', + 'expiration' => $time + ], + [ + 'name' => 'session3', + 'expiration' => $time + ], + [ + 'name' => 'session4', + 'expiration' => $time + ] + ], + 'inUse' => [], + 'toCreate' => [] + ])); + $pool->setDatabase($this->getDatabase(false, true)); + $response = $pool->downsize($percent); + + $this->assertEquals($expectedDeleteCount, $response['deleted']); + } + + public function downsizeDataProvider() + { + return [ + [50, 2], + [1, 1], + [100, 4] + ]; + } + + public function testDownsizeFails() + { + $time = time() + 3600; + $pool = new CacheSessionPoolStub($this->getCacheItemPool([ + 'queue' => [ + [ + 'name' => 'session0', + 'expiration' => $time + ], + [ + 'name' => 'session1', + 'expiration' => $time + ] + ], + 'inUse' => [], + 'toCreate' => [] + ])); + $pool->setDatabase($this->getDatabase()); + $response = $pool->downsize(100); + + $this->assertEquals(0, $response['deleted']); + $this->assertEquals(1, count($response['failed'])); + $this->assertContainsOnlyInstancesOf(Session::class, $response['failed']); + } + + /** + * @dataProvider invalidPercentDownsizeDataProvider + */ + public function testDownsizeThrowsExceptionWithInvalidPercent($percent) + { + $pool = new CacheSessionPoolStub($this->getCacheItemPool()); + $exceptionThrown = false; + + try { + $pool->downsize($percent); + } catch (\InvalidArgumentException $ex) { + $exceptionThrown = true; + } + + $this->assertTrue($exceptionThrown); + } + + public function invalidPercentDownsizeDataProvider() + { + return [ + [-1], + [0], + [101] + ]; + } + + public function testWarmup() + { + $expectedCreationCount = 5; + $pool = new CacheSessionPoolStub( + $this->getCacheItemPool(), + ['minSessions' => $expectedCreationCount] + ); + $pool->setDatabase($this->getDatabase()); + $response = $pool->warmup(); + + $this->assertEquals($expectedCreationCount, $response); + } + public function testClearPool() { $pool = new CacheSessionPoolStub($this->getCacheItemPool()); + $pool->setDatabase($this->getDatabase()); $pool->clear(); $actualItemPool = $pool->cacheItemPool(); $actualCacheData = $actualItemPool->getItem( - sprintf(self::CACHE_KEY_TEMPLATE, self::INSTANCE_NAME, self::DATABASE_NAME) + sprintf(self::CACHE_KEY_TEMPLATE, self::PROJECT_ID, self::INSTANCE_NAME, self::DATABASE_NAME) )->get(); $this->assertNull($actualCacheData); @@ -203,7 +350,7 @@ public function testAcquire($config, $cacheData, $expectedCacheData, $time) $actualSession = $pool->acquire(); $actualItemPool = $pool->cacheItemPool(); $actualCacheData = $actualItemPool->getItem( - sprintf(self::CACHE_KEY_TEMPLATE, self::INSTANCE_NAME, self::DATABASE_NAME) + sprintf(self::CACHE_KEY_TEMPLATE, self::PROJECT_ID, self::INSTANCE_NAME, self::DATABASE_NAME) )->get(); $this->assertInstanceOf(Session::class, $actualSession); @@ -232,37 +379,24 @@ public function acquireDataProvider() ], $time ], - // Set #1: Purge expired session from queue and create sessions up to min + // Set #1: Purge expired session from queue and create [ - ['minSessions' => 3], + ['minSessions' => 1], [ 'queue' => [ [ 'name' => 'expired', 'expiration' => $time - 3000 - ], - [ - 'name' => 'stillValid', - 'expiration' => $time + 3600 ] ], 'inUse' => [], 'toCreate' => [] ], [ - 'queue' => [ - [ - 'name' => 'session0', - 'expiration' => $time + 3600 - ], - [ - 'name' => 'session1', - 'expiration' => $time + 3600 - ] - ], + 'queue' => [], 'inUse' => [ - 'stillValid' => [ - 'name' => 'stillValid', + 'session0' => [ + 'name' => 'session0', 'expiration' => $time + 3600, 'lastActive' => $time ] @@ -271,8 +405,8 @@ public function acquireDataProvider() ], $time ], - // // Set #2: Create a new session when all available are checked out - // // and we have not reached the max limit + // Set #2: Create a new session when all available are checked out + // and we have not reached the max limit [ [], [ @@ -318,7 +452,7 @@ public function acquireDataProvider() 'expiredInUse2' => [ 'name' => 'expiredInUse2', 'expiration' => $time - 5000, - 'lastActive' => $time - 1201 + 'lastActive' => $time - 3601 ] ], 'toCreate' => [ @@ -393,11 +527,38 @@ public function acquireDataProvider() 'toCreate' => [] ], $time - ] + ], + // Set #6: Return inactive in use session back to queue + [ + ['maxSessions' => 1], + [ + 'queue' => [], + 'inUse' => [ + 'inactiveInUse1' => [ + 'name' => 'inactiveInUse1', + 'expiration' => $time + 3600, + 'lastActive' => $time - 1201 + ] + ], + 'toCreate' => [] + ], + [ + 'queue' => [], + 'inUse' => [ + 'inactiveInUse1' => [ + 'name' => 'inactiveInUse1', + 'expiration' => $time + 3600, + 'lastActive' => $time + ] + ], + 'toCreate' => [] + ], + $time + ], ]; } - private function getDatabase($shouldCreateThrowException = false) + private function getDatabase($shouldCreateThrowException = false, $willDeleteSessions = false) { $database = $this->prophesize(Database::class); $createdSession = $this->prophesize(Session::class); @@ -408,6 +569,11 @@ private function getDatabase($shouldCreateThrowException = false) ->willReturn($this->time + 3600); $session->exists() ->willReturn(false); + + if ($willDeleteSessions) { + $session->delete() + ->willReturn(null); + } $database->session(Argument::any()) ->will(function ($args) use ($session) { $session->name() @@ -417,6 +583,7 @@ private function getDatabase($shouldCreateThrowException = false) }); $database->identity() ->willReturn([ + 'projectId' => self::PROJECT_ID, 'database' => self::DATABASE_NAME, 'instance' => self::INSTANCE_NAME ]); @@ -446,7 +613,7 @@ private function getCacheItemPool(array $cacheData = null) { $cacheItemPool = new MemoryCacheItemPool(); $cacheItem = $cacheItemPool->getItem( - sprintf(self::CACHE_KEY_TEMPLATE, self::INSTANCE_NAME, self::DATABASE_NAME) + sprintf(self::CACHE_KEY_TEMPLATE, self::PROJECT_ID, self::INSTANCE_NAME, self::DATABASE_NAME) ); $cacheItemPool->save($cacheItem->set($cacheData)); @@ -466,6 +633,6 @@ public function __construct(CacheItemPoolInterface $cacheItemPool, array $config protected function time() { - return $this->time ?: parent::$this->time(); + return $this->time ?: parent::time(); } } diff --git a/tests/unit/Spanner/ValueMapperTest.php b/tests/unit/Spanner/ValueMapperTest.php index 614860f9dde4..e59def509444 100644 --- a/tests/unit/Spanner/ValueMapperTest.php +++ b/tests/unit/Spanner/ValueMapperTest.php @@ -20,6 +20,7 @@ use Google\Cloud\Core\Int64; use Google\Cloud\Spanner\Bytes; use Google\Cloud\Spanner\Date; +use Google\Cloud\Spanner\Result; use Google\Cloud\Spanner\Timestamp; use Google\Cloud\Spanner\ValueMapper; @@ -28,6 +29,8 @@ */ class ValueMapperTest extends \PHPUnit_Framework_TestCase { + const FORMAT_TEST_VALUE = 'abc'; + private $mapper; public function setUp() @@ -171,11 +174,63 @@ public function testEncodeValuesAsSimpleType() $this->assertEquals($vals['array'], $res[10]); } + /** + * @expectedException \InvalidArgumentException + */ + public function testDecodeValuesThrowsExceptionWithInvalidFormat() + { + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_STRING), + $this->createRow(self::FORMAT_TEST_VALUE), + 'Not a real format' + ); + } + + /** + * @dataProvider formatProvider + */ + public function testDecodeValuesReturnsVariedFormats($expectedOutput, $format) + { + $res = $this->mapper->decodeValues( + $this->createField(ValueMapper::TYPE_STRING), + $this->createRow(self::FORMAT_TEST_VALUE), + $format + ); + + $this->assertEquals($expectedOutput, $res); + } + + public function formatProvider() + { + return [ + [ + ['rowName' => self::FORMAT_TEST_VALUE], + Result::RETURN_ASSOCIATIVE + ], + [ + [ + [ + 'name' => 'rowName', + 'value' => self::FORMAT_TEST_VALUE + ] + ], + Result::RETURN_NAME_VALUE_PAIR + ], + [ + [ + 0 => self::FORMAT_TEST_VALUE + ], + Result::RETURN_ZERO_INDEXED + ], + ]; + } + public function testDecodeValuesBool() { $res = $this->mapper->decodeValues( $this->createField(ValueMapper::TYPE_BOOL), - $this->createRow(false) + $this->createRow(false), + Result::RETURN_ASSOCIATIVE ); $this->assertEquals(false, $res['rowName']); } @@ -184,7 +239,8 @@ public function testDecodeValuesInt() { $res = $this->mapper->decodeValues( $this->createField(ValueMapper::TYPE_INT64), - $this->createRow('555') + $this->createRow('555'), + Result::RETURN_ASSOCIATIVE ); $this->assertEquals(555, $res['rowName']); } @@ -194,7 +250,8 @@ public function testDecodeValuesInt64Object() $mapper = new ValueMapper(true); $res = $mapper->decodeValues( $this->createField(ValueMapper::TYPE_INT64), - $this->createRow('555') + $this->createRow('555'), + Result::RETURN_ASSOCIATIVE ); $this->assertInstanceOf(Int64::class, $res['rowName']); $this->assertEquals('555', $res['rowName']->get()); @@ -204,7 +261,8 @@ public function testDecodeValuesFloat() { $res = $this->mapper->decodeValues( $this->createField(ValueMapper::TYPE_FLOAT64), - $this->createRow(3.1415) + $this->createRow(3.1415), + Result::RETURN_ASSOCIATIVE ); $this->assertEquals(3.1415, $res['rowName']); } @@ -213,7 +271,8 @@ public function testDecodeValuesFloatNaN() { $res = $this->mapper->decodeValues( $this->createField(ValueMapper::TYPE_FLOAT64), - $this->createRow('NaN') + $this->createRow('NaN'), + Result::RETURN_ASSOCIATIVE ); $this->assertTrue(is_nan($res['rowName'])); } @@ -222,7 +281,8 @@ public function testDecodeValuesFloatInfinity() { $res = $this->mapper->decodeValues( $this->createField(ValueMapper::TYPE_FLOAT64), - $this->createRow('Infinity') + $this->createRow('Infinity'), + Result::RETURN_ASSOCIATIVE ); $this->assertTrue(is_infinite($res['rowName'])); @@ -233,7 +293,8 @@ public function testDecodeValuesFloatNegativeInfinity() { $res = $this->mapper->decodeValues( $this->createField(ValueMapper::TYPE_FLOAT64), - $this->createRow('-Infinity') + $this->createRow('-Infinity'), + Result::RETURN_ASSOCIATIVE ); $this->assertTrue(is_infinite($res['rowName'])); @@ -247,7 +308,8 @@ public function testDecodeValuesFloatError() { $res = $this->mapper->decodeValues( $this->createField(ValueMapper::TYPE_FLOAT64), - $this->createRow('foo') + $this->createRow('foo'), + Result::RETURN_ASSOCIATIVE ); } @@ -255,7 +317,8 @@ public function testDecodeValuesString() { $res = $this->mapper->decodeValues( $this->createField(ValueMapper::TYPE_STRING), - $this->createRow('foo') + $this->createRow('foo'), + Result::RETURN_ASSOCIATIVE ); $this->assertEquals('foo', $res['rowName']); } @@ -265,7 +328,8 @@ public function testDecodeValuesTimestamp() $dt = new \DateTime; $res = $this->mapper->decodeValues( $this->createField(ValueMapper::TYPE_TIMESTAMP), - $this->createRow($dt->format(Timestamp::FORMAT)) + $this->createRow($dt->format(Timestamp::FORMAT)), + Result::RETURN_ASSOCIATIVE ); $this->assertInstanceOf(Timestamp::class, $res['rowName']); @@ -277,7 +341,8 @@ public function testDecodeValuesDate() $dt = new \DateTime; $res = $this->mapper->decodeValues( $this->createField(ValueMapper::TYPE_DATE), - $this->createRow($dt->format(Date::FORMAT)) + $this->createRow($dt->format(Date::FORMAT)), + Result::RETURN_ASSOCIATIVE ); $this->assertInstanceOf(Date::class, $res['rowName']); @@ -288,7 +353,8 @@ public function testDecodeValuesBytes() { $res = $this->mapper->decodeValues( $this->createField(ValueMapper::TYPE_BYTES), - $this->createRow(base64_encode('hello world')) + $this->createRow(base64_encode('hello world')), + Result::RETURN_ASSOCIATIVE ); $this->assertInstanceOf(Bytes::class, $res['rowName']); @@ -300,7 +366,9 @@ public function testDecodeValuesArray() $res = $this->mapper->decodeValues( $this->createField(ValueMapper::TYPE_ARRAY, 'arrayElementType', [ 'code' => ValueMapper::TYPE_STRING - ]), $this->createRow(['foo', 'bar']) + ]), + $this->createRow(['foo', 'bar']), + Result::RETURN_ASSOCIATIVE ); $this->assertEquals('foo', $res['rowName'][0]); @@ -337,7 +405,8 @@ public function testDecodeValuesStruct() $res = $this->mapper->decodeValues( [$field], - [$row] + [$row], + Result::RETURN_ASSOCIATIVE ); $this->assertEquals('Hello World', $res['structTest'][0]['rowName']); @@ -360,7 +429,7 @@ public function testDecodeValuesAnonymousField() $row = ['1337', 'John']; - $res = $this->mapper->decodeValues($fields, $row); + $res = $this->mapper->decodeValues($fields, $row, Result::RETURN_ASSOCIATIVE); $this->assertEquals('1337', $res['ID']); $this->assertEquals('John', $res[1]); diff --git a/tests/unit/fixtures/spanner/streaming-read-acceptance-test.json b/tests/unit/fixtures/spanner/streaming-read-acceptance-test.json index 718e782c9839..38b549b5cb9f 100644 --- a/tests/unit/fixtures/spanner/streaming-read-acceptance-test.json +++ b/tests/unit/fixtures/spanner/streaming-read-acceptance-test.json @@ -366,6 +366,82 @@ "{\n \"values\": [\"f\"]\n}" ], "name": "Multiple Row Chunks/Non Chunks Interleaved" + }, + { + "result": { + "value": [ + [ + "a" + ], + [ + "b" + ], + [ + "c" + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 6\n }\n }]\n }\n },\n \"values\": [\"a\"]\n}", + "{\n \"values\": [\"b\"]\n}", + "{\n \"values\": [\"c\"],\n \"resumeToken\": \"theToken\"\n}" + ], + "name": "Buffers up to resume token" + }, + { + "result": { + "value": [ + [ + "a" + ], + [ + "b" + ], + [ + "c" + ], + [ + "d" + ], + [ + "e" + ], + [ + "f" + ], + [ + "g" + ], + [ + "h" + ], + [ + "i" + ] + ] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 6\n }\n }]\n }\n },\n \"values\": [\"a\"]\n}", + "{\n \"values\": [\"b\"]\n}", + "{\n \"values\": [\"c\"]\n}", + "{\n \"values\": [\"d\"]\n}", + "{\n \"values\": [\"e\"]\n}", + "{\n \"values\": [\"f\"]\n}", + "{\n \"values\": [\"g\"]\n}", + "{\n \"values\": [\"h\"]\n}", + "{\n \"values\": [\"i\"]\n}", + "{\n \"values\": [\"j\"],\n \"chunkedValue\": true\n}" + ], + "name": "Buffers up to buffer limit" + }, + { + "result": { + "value": [] + }, + "chunks": [ + "{\n \"metadata\": {\n \"rowType\": {\n \"fields\": [{\n \"name\": \"f1\",\n \"type\": {\n \"code\": 6\n }\n }]\n }\n }}" + ], + "name": "Returns early with no values" } ] } From 9becfc7e0ab4d4408f1bd4bf297b966a1c212c3b Mon Sep 17 00:00:00 2001 From: jdpedrie Date: Tue, 16 May 2017 10:43:02 -0400 Subject: [PATCH 07/11] Update README and Spanner composer.json --- README.md | 38 ++++++++++++++++++++++ src/Spanner/composer.json | 8 +++-- tests/snippets/Spanner/TransactionTest.php | 13 +++++++- tests/system/Spanner/ReadTest.php | 9 +---- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8fc1b37ab204..419a754033b5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This client supports the following Google Cloud Platform services at a [Beta](#v This client supports the following Google Cloud Platform services at an [Alpha](#versioning) quality level: * [Google Cloud Pub/Sub](#google-cloud-pubsub-alpha) (Alpha) +* [Cloud Spanner](#cloud-spanner-alpha) (Alpha) * [Google Cloud Speech](#google-cloud-speech-alpha) (Alpha) * [Google Stackdriver Trace](#google-stackdriver-trace-alpha) (Alpha) @@ -390,6 +391,43 @@ Google Cloud Pub/Sub can be installed separately by requiring the `google/cloud- $ require google/cloud-pubsub ``` +## Cloud Spanner (Alpha) + +- [API Documentation](http://googlecloudplatform.github.io/google-cloud-php/#/docs/latest/spanner/spannerclient) +- [Official Documentation](https://cloud.google.com/spanner/docs) + +#### Preview + +```php +require 'vendor/autoload.php'; + +use Google\Cloud\Spanner\SpannerClient; + +$spanner = new SpannerClient([ + 'projectId' => 'my_project' +]); + +$db = $spanner->connect('my-instance', 'my-database'); + +$userQuery = $db->execute('SELECT * FROM Users WHERE id = @id', [ + 'parameters' => [ + 'id' => $userId + ] +]); + +$user = $userQuery->rows()->current(); + +echo 'Hello ' . $user['firstName']; +``` + +#### google/cloud-spanner + +Cloud Spanner can be installed separately by requiring the `google/cloud-spanner` composer package: + +``` +$ require google/cloud-spanner +``` + ## Google Cloud Speech (Alpha) - [API Documentation](http://googlecloudplatform.github.io/google-cloud-php/#/docs/latest/speech/speechclient) diff --git a/src/Spanner/composer.json b/src/Spanner/composer.json index c71551886e92..dbe67efd14e5 100644 --- a/src/Spanner/composer.json +++ b/src/Spanner/composer.json @@ -4,11 +4,13 @@ "license": "Apache-2.0", "minimum-stability": "stable", "require": { - "google/cloud-core": "*" + "ext-grpc": "*", + "google/cloud-core": "^1.0", + "google/gax": "^0.8", + "google/proto-client-php": "^0.8" }, "suggest": { - "google/gax": "Required to support gRPC", - "google/proto-client-php": "Required to support gRPC" + "symfony/lock": "Required for the default session handler. Should be included as follows: symfony/lock:dev-master#1ba6ac9" }, "extra": { "component": { diff --git a/tests/snippets/Spanner/TransactionTest.php b/tests/snippets/Spanner/TransactionTest.php index 2c6596ef9e08..7514fd2e490f 100644 --- a/tests/snippets/Spanner/TransactionTest.php +++ b/tests/snippets/Spanner/TransactionTest.php @@ -49,7 +49,7 @@ public function setUp() $operation->reveal(), $session->reveal(), self::TRANSACTION - ], ['operation']); + ], ['operation', 'isRetry']); } private function stubOperation($stub = null) @@ -296,6 +296,17 @@ public function testState() $this->assertEquals(Transaction::STATE_ACTIVE, $res->returnVal()); } + public function testIsRetry() + { + $snippet = $this->snippetFromMethod(Transaction::class, 'isRetry'); + $snippet->addLocal('transaction', $this->transaction); + + $this->transaction->___setProperty('isRetry', true); + + $res = $snippet->invoke(); + $this->assertEquals('This is a retry transaction!', $res->output()); + } + private function resultGenerator() { yield [ diff --git a/tests/system/Spanner/ReadTest.php b/tests/system/Spanner/ReadTest.php index ed6eb1e025ce..992489cf3bef 100644 --- a/tests/system/Spanner/ReadTest.php +++ b/tests/system/Spanner/ReadTest.php @@ -82,14 +82,6 @@ public function testReadPoint() } } - // public function rangeProvider() - // { - // return [ - // // single key, open-open - - // ]; - // } - /** * covers 8 */ @@ -353,6 +345,7 @@ public function testBindBytesParameter() $row = $res->rows()->current(); $this->assertInstanceOf(Bytes::class, $row['foo']); $this->assertEquals($str, base64_decode($bytes->formatAsString())); + $this->assertEquals($str, (string)$bytes->get()); } /** From b78a47e5fd4b7325cf22d82767fb6498137a5157 Mon Sep 17 00:00:00 2001 From: Dave Supplee Date: Tue, 16 May 2017 18:34:20 -0400 Subject: [PATCH 08/11] update verbiage --- src/Spanner/Session/CacheSessionPool.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Spanner/Session/CacheSessionPool.php b/src/Spanner/Session/CacheSessionPool.php index 56e369955506..0149952cca62 100644 --- a/src/Spanner/Session/CacheSessionPool.php +++ b/src/Spanner/Session/CacheSessionPool.php @@ -47,7 +47,9 @@ * on an interval that matches when you expect to see a decrease in traffic. * This will help ensure you never run into issues where the Spanner backend is * locked up after having met the maximum number of sessions assigned per node - * (The current maximum sessions per node is 10k). + * For reference, the current maximum sessions per database per node is 10k. For + * more information on limits please see + * [here](https://cloud.google.com/spanner/docs/limits). * * Additionally, when expecting a long period of inactivity (such as a * maintenance window), please make sure to call From 57849d37c0d03de0d65f3f2648c5b64edd2b52fd Mon Sep 17 00:00:00 2001 From: Dave Supplee Date: Wed, 17 May 2017 14:01:57 -0400 Subject: [PATCH 09/11] small tweaks --- src/Spanner/Session/CacheSessionPool.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Spanner/Session/CacheSessionPool.php b/src/Spanner/Session/CacheSessionPool.php index 0149952cca62..229d299404b9 100644 --- a/src/Spanner/Session/CacheSessionPool.php +++ b/src/Spanner/Session/CacheSessionPool.php @@ -46,7 +46,7 @@ * the {@see Google\Cloud\Spanner\Session\CacheSessionPool::downsize()} method * on an interval that matches when you expect to see a decrease in traffic. * This will help ensure you never run into issues where the Spanner backend is - * locked up after having met the maximum number of sessions assigned per node + * locked up after having met the maximum number of sessions assigned per node. * For reference, the current maximum sessions per database per node is 10k. For * more information on limits please see * [here](https://cloud.google.com/spanner/docs/limits). @@ -176,7 +176,7 @@ public function acquire($context = SessionPoolInterface::CONTEXT_READ) list($session, $toCreate) = $this->config['lock']->synchronize(function () { $toCreate = []; $session = null; - $shouldSave = true; + $shouldSave = false; $item = $this->cacheItemPool->getItem($this->cacheKey); $data = (array) $item->get() ?: $this->initialize(); @@ -186,10 +186,12 @@ public function acquire($context = SessionPoolInterface::CONTEXT_READ) // for more. if ($data['queue']) { $session = $this->getSession($data); + $shouldSave = true; } elseif ($this->config['maxSessions'] <= $this->getSessionCount($data)) { $this->purgeOrphanedInUseSessions($data); $this->purgeOrphanedToCreateItems($data); $session = $this->getSession($data); + $shouldSave = true; } if (!$session) { @@ -198,8 +200,7 @@ public function acquire($context = SessionPoolInterface::CONTEXT_READ) if ($count < $this->config['maxSessions']) { $toCreate = $this->buildToCreateList(1); $data['toCreate'] += $toCreate; - } else { - $shouldSave = false; + $shouldSave = true; } } @@ -541,7 +542,7 @@ private function buildToCreateList($number) $time = $this->time(); for ($i = 0; $i < $number; $i++) { - $toCreate[uniqid($time . '_')] = $time; + $toCreate[uniqid($time . '_', true)] = $time; } return $toCreate; From 22b994d85976054585fc48ea8b2e73dfe3a1908b Mon Sep 17 00:00:00 2001 From: jdpedrie Date: Wed, 17 May 2017 16:25:40 -0400 Subject: [PATCH 10/11] Add resource-prefix header to outgoing spanner requests --- composer.json | 2 +- src/Spanner/Connection/Grpc.php | 130 ++++++++++++++------- src/Spanner/Database.php | 15 +++ src/Spanner/Instance.php | 3 +- src/Spanner/Operation.php | 13 ++- src/Spanner/Session/Session.php | 6 +- src/Spanner/composer.json | 2 +- tests/unit/Spanner/Connection/GrpcTest.php | 100 ++++++++++------ tests/unit/Spanner/OperationTest.php | 2 + 9 files changed, 184 insertions(+), 89 deletions(-) diff --git a/composer.json b/composer.json index 4d56dddc8efe..81858f65719a 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,7 @@ "erusev/parsedown": "^1.6", "vierbergenlars/php-semver": "^3.0", "google/proto-client-php": "^0.12", - "google/gax": "^0.8", + "google/gax": "^0.9", "symfony/lock": "dev-master#1ba6ac9" }, "suggest": { diff --git a/src/Spanner/Connection/Grpc.php b/src/Spanner/Connection/Grpc.php index d5a5f9b9ca22..738d57e7b379 100644 --- a/src/Spanner/Connection/Grpc.php +++ b/src/Spanner/Connection/Grpc.php @@ -144,9 +144,10 @@ public function __construct(array $config = []) */ public function listInstanceConfigs(array $args) { + $projectId = $this->pluck('projectId', $args); return $this->send([$this->instanceAdminClient, 'listInstanceConfigs'], [ - $this->pluck('projectId', $args), - $args + $projectId, + $this->addResourcePrefixHeader($args, $projectId) ]); } @@ -155,9 +156,10 @@ public function listInstanceConfigs(array $args) */ public function getInstanceConfig(array $args) { + $projectId = $this->pluck('projectId', $args); return $this->send([$this->instanceAdminClient, 'getInstanceConfig'], [ $this->pluck('name', $args), - $args + $this->addResourcePrefixHeader($args, $projectId) ]); } @@ -166,9 +168,10 @@ public function getInstanceConfig(array $args) */ public function listInstances(array $args) { + $projectId = $this->pluck('projectId', $args); return $this->send([$this->instanceAdminClient, 'listInstances'], [ - $this->pluck('projectId', $args), - $args + $projectId, + $this->addResourcePrefixHeader($args, $projectId) ]); } @@ -177,9 +180,10 @@ public function listInstances(array $args) */ public function getInstance(array $args) { + $projectId = $this->pluck('projectId', $args); return $this->send([$this->instanceAdminClient, 'getInstance'], [ $this->pluck('name', $args), - $args + $this->addResourcePrefixHeader($args, $projectId) ]); } @@ -188,12 +192,14 @@ public function getInstance(array $args) */ public function createInstance(array $args) { + $instanceName = $args['name']; + $instance = $this->instanceObject($args, true); $res = $this->send([$this->instanceAdminClient, 'createInstance'], [ $this->pluck('projectId', $args), $this->pluck('instanceId', $args), $instance, - $args + $this->addResourcePrefixHeader($args, $instanceName) ]); return $this->operationToArray($res, $this->codec, $this->lroResponseMappers); @@ -204,6 +210,7 @@ public function createInstance(array $args) */ public function updateInstance(array $args) { + $instanceName = $args['name']; $instanceObject = $this->instanceObject($args); $mask = array_keys($instanceObject->serialize(new PhpArray([], false))); @@ -213,7 +220,7 @@ public function updateInstance(array $args) $res = $this->send([$this->instanceAdminClient, 'updateInstance'], [ $instanceObject, $fieldMask, - $args + $this->addResourcePrefixHeader($args, $instanceName) ]); return $this->operationToArray($res, $this->codec, $this->lroResponseMappers); @@ -224,9 +231,10 @@ public function updateInstance(array $args) */ public function deleteInstance(array $args) { + $instanceName = $this->pluck('name', $args); return $this->send([$this->instanceAdminClient, 'deleteInstance'], [ - $this->pluck('name', $args), - $args + $instanceName, + $this->addResourcePrefixHeader($args, $instanceName) ]); } @@ -235,9 +243,10 @@ public function deleteInstance(array $args) */ public function getInstanceIamPolicy(array $args) { + $resource = $this->pluck('resource', $args); return $this->send([$this->instanceAdminClient, 'getIamPolicy'], [ - $this->pluck('resource', $args), - $args + $resource, + $this->addResourcePrefixHeader($args, $resource) ]); } @@ -246,10 +255,11 @@ public function getInstanceIamPolicy(array $args) */ public function setInstanceIamPolicy(array $args) { + $resource = $this->pluck('resource', $args); return $this->send([$this->instanceAdminClient, 'setIamPolicy'], [ - $this->pluck('resource', $args), + $resource, $this->pluck('policy', $args), - $args + $this->addResourcePrefixHeader($args, $resource) ]); } @@ -258,10 +268,11 @@ public function setInstanceIamPolicy(array $args) */ public function testInstanceIamPermissions(array $args) { + $resource = $this->pluck('resource', $args); return $this->send([$this->instanceAdminClient, 'testIamPermissions'], [ - $this->pluck('resource', $args), + $resource, $this->pluck('permissions', $args), - $args + $this->addResourcePrefixHeader($args, $resource) ]); } @@ -270,9 +281,10 @@ public function testInstanceIamPermissions(array $args) */ public function listDatabases(array $args) { + $instanceName = $this->pluck('instance', $args); return $this->send([$this->databaseAdminClient, 'listDatabases'], [ - $this->pluck('instance', $args), - $args + $instanceName, + $this->addResourcePrefixHeader($args, $instanceName) ]); } @@ -281,11 +293,12 @@ public function listDatabases(array $args) */ public function createDatabase(array $args) { + $instanceName = $this->pluck('instance', $args); $res = $this->send([$this->databaseAdminClient, 'createDatabase'], [ - $this->pluck('instance', $args), + $instanceName, $this->pluck('createStatement', $args), $this->pluck('extraStatements', $args), - $args + $this->addResourcePrefixHeader($args, $instanceName) ]); return $this->operationToArray($res, $this->codec, $this->lroResponseMappers); @@ -296,10 +309,11 @@ public function createDatabase(array $args) */ public function updateDatabaseDdl(array $args) { + $databaseName = $this->pluck('name', $args); $res = $this->send([$this->databaseAdminClient, 'updateDatabaseDdl'], [ - $this->pluck('name', $args), + $databaseName, $this->pluck('statements', $args), - $args + $this->addResourcePrefixHeader($args, $databaseName) ]); return $this->operationToArray($res, $this->codec, $this->lroResponseMappers); @@ -310,9 +324,10 @@ public function updateDatabaseDdl(array $args) */ public function dropDatabase(array $args) { + $databaseName = $this->pluck('name', $args); return $this->send([$this->databaseAdminClient, 'dropDatabase'], [ - $this->pluck('name', $args), - $args + $databaseName, + $this->addResourcePrefixHeader($args, $databaseName) ]); } @@ -321,9 +336,10 @@ public function dropDatabase(array $args) */ public function getDatabase(array $args) { + $databaseName = $this->pluck('name', $args); return $this->send([$this->databaseAdminClient, 'getDatabase'], [ - $this->pluck('name', $args), - $args + $databaseName, + $this->addResourcePrefixHeader($args, $databaseName) ]); } @@ -332,9 +348,10 @@ public function getDatabase(array $args) */ public function getDatabaseDDL(array $args) { + $databaseName = $this->pluck('name', $args); return $this->send([$this->databaseAdminClient, 'getDatabaseDDL'], [ - $this->pluck('name', $args), - $args + $databaseName, + $this->addResourcePrefixHeader($args, $databaseName) ]); } @@ -343,9 +360,10 @@ public function getDatabaseDDL(array $args) */ public function getDatabaseIamPolicy(array $args) { + $databaseName = $this->pluck('resource', $args); return $this->send([$this->databaseAdminClient, 'getIamPolicy'], [ - $this->pluck('resource', $args), - $args + $databaseName, + $this->addResourcePrefixHeader($args, $databaseName) ]); } @@ -354,10 +372,11 @@ public function getDatabaseIamPolicy(array $args) */ public function setDatabaseIamPolicy(array $args) { + $databaseName = $this->pluck('resource', $args); return $this->send([$this->databaseAdminClient, 'setIamPolicy'], [ - $this->pluck('resource', $args), + $databaseName, $this->pluck('policy', $args), - $args + $this->addResourcePrefixHeader($args, $databaseName) ]); } @@ -366,10 +385,11 @@ public function setDatabaseIamPolicy(array $args) */ public function testDatabaseIamPermissions(array $args) { + $databaseName = $this->pluck('resource', $args); return $this->send([$this->databaseAdminClient, 'testIamPermissions'], [ - $this->pluck('resource', $args), + $databaseName, $this->pluck('permissions', $args), - $args + $this->addResourcePrefixHeader($args, $databaseName) ]); } @@ -378,9 +398,10 @@ public function testDatabaseIamPermissions(array $args) */ public function createSession(array $args) { + $databaseName = $this->pluck('database', $args); return $this->send([$this->spannerClient, 'createSession'], [ - $this->pluck('database', $args), - $args + $databaseName, + $this->addResourcePrefixHeader($args, $databaseName) ]); } @@ -389,9 +410,10 @@ public function createSession(array $args) */ public function getSession(array $args) { + $database = $this->pluck('database', $args); return $this->send([$this->spannerClient, 'getSession'], [ $this->pluck('name', $args), - $args + $this->addResourcePrefixHeader($args, $database) ]); } @@ -400,9 +422,10 @@ public function getSession(array $args) */ public function deleteSession(array $args) { + $database = $this->pluck('database', $args); return $this->send([$this->spannerClient, 'deleteSession'], [ $this->pluck('name', $args), - $args + $this->addResourcePrefixHeader($args, $database) ]); } @@ -438,10 +461,11 @@ public function executeStreamingSql(array $args) $args['transaction'] = $this->createTransactionSelector($args); + $database = $this->pluck('database', $args); return $this->send([$this->spannerClient, 'executeStreamingSql'], [ $this->pluck('session', $args), $this->pluck('sql', $args), - $args + $this->addResourcePrefixHeader($args, $database) ]); } @@ -457,12 +481,13 @@ public function streamingRead(array $args) $args['transaction'] = $this->createTransactionSelector($args); + $database = $this->pluck('database', $args); return $this->send([$this->spannerClient, 'streamingRead'], [ $this->pluck('session', $args), $this->pluck('table', $args), $this->pluck('columns', $args), $keySet, - $args + $this->addResourcePrefixHeader($args, $database) ]); } @@ -484,10 +509,11 @@ public function beginTransaction(array $args) $options->setReadWrite($readWrite); } + $database = $this->pluck('database', $args); return $this->send([$this->spannerClient, 'beginTransaction'], [ $this->pluck('session', $args), $options, - $args + $this->addResourcePrefixHeader($args, $database) ]); } @@ -547,10 +573,11 @@ public function commit(array $args) $args['singleUseTransaction'] = $options; } + $database = $this->pluck('database', $args); return $this->send([$this->spannerClient, 'commit'], [ $this->pluck('session', $args), $mutations, - $args + $this->addResourcePrefixHeader($args, $database) ]); } @@ -559,10 +586,11 @@ public function commit(array $args) */ public function rollback(array $args) { + $database = $this->pluck('database', $args); return $this->send([$this->spannerClient, 'rollback'], [ $this->pluck('session', $args), $this->pluck('transactionId', $args), - $args + $this->addResourcePrefixHeader($args, $database) ]); } @@ -766,4 +794,20 @@ private function formatTransactionOptions(array $transactionOptions) return $transactionOptions; } + + /** + * Add the `google-cloud-resource-prefix` header value to the request. + * + * @param array $args + * @param string $value + * @return array + */ + private function addResourcePrefixHeader(array $args, $value) + { + $args['userHeaders'] = [ + 'google-cloud-resource-prefix' => [$value] + ]; + + return $args; + } } diff --git a/src/Spanner/Database.php b/src/Spanner/Database.php index 331eaa22a30c..dae8ca7c088a 100644 --- a/src/Spanner/Database.php +++ b/src/Spanner/Database.php @@ -709,6 +709,11 @@ public function runTransaction(callable $operation, array $options = []) * * Mutations are committed in a single-use transaction. * + * Since this method does not feature replay protection, it may attempt to + * apply mutations more than once; if the mutations are not idempotent, this + * may lead to a failure being reported when the mutation was previously + * applied. + * * Example: * ``` * $database->insert('Posts', [ @@ -737,6 +742,11 @@ public function insert($table, array $data, array $options = []) * * Mutations are committed in a single-use transaction. * + * Since this method does not feature replay protection, it may attempt to + * apply mutations more than once; if the mutations are not idempotent, this + * may lead to a failure being reported when the mutation was previously + * applied. + * * Example: * ``` * $database->insertBatch('Posts', [ @@ -998,6 +1008,11 @@ public function replaceBatch($table, array $dataSet, array $options = []) * * Mutations are committed in a single-use transaction. * + * Since this method does not feature replay protection, it may attempt to + * apply mutations more than once; if the mutations are not idempotent, this + * may lead to a failure being reported when the mutation was previously + * applied. + * * Example: * ``` * $keySet = new KeySet([ diff --git a/src/Spanner/Instance.php b/src/Spanner/Instance.php index 2c9bfa5dfe03..49dddc6fe6f8 100644 --- a/src/Spanner/Instance.php +++ b/src/Spanner/Instance.php @@ -235,7 +235,8 @@ public function exists(array $options = []) public function reload(array $options = []) { $this->info = $this->connection->getInstance($options + [ - 'name' => $this->name + 'name' => $this->name, + 'projectId' => $this->projectId ]); return $this->info; diff --git a/src/Spanner/Operation.php b/src/Spanner/Operation.php index 54ce03b2ca50..5f4a0b7768a1 100644 --- a/src/Spanner/Operation.php +++ b/src/Spanner/Operation.php @@ -127,7 +127,8 @@ public function commit(Session $session, array $mutations, array $options = []) $res = $this->connection->commit($this->arrayFilterRemoveNull([ 'mutations' => $mutations, - 'session' => $session->name() + 'session' => $session->name(), + 'database' => $session->info()['database'] ]) + $options); return $this->mapper->createTimestampWithNanos($res['commitTimestamp']); @@ -147,7 +148,8 @@ public function rollback(Session $session, $transactionId, array $options = []) { return $this->connection->rollback([ 'transactionId' => $transactionId, - 'session' => $session->name() + 'session' => $session->name(), + 'database' => $session->info()['database'] ] + $options); } @@ -180,7 +182,8 @@ public function execute(Session $session, $sql, array $options = []) return $this->connection->executeStreamingSql([ 'sql' => $sql, - 'session' => $session->name() + 'session' => $session->name(), + 'database' => $session->info()['database'] ] + $options); }; @@ -223,7 +226,8 @@ public function read(Session $session, $table, KeySet $keySet, array $columns, a 'table' => $table, 'session' => $session->name(), 'columns' => $columns, - 'keySet' => $this->flattenKeySet($keySet) + 'keySet' => $this->flattenKeySet($keySet), + 'database' => $session->info()['database'] ] + $options); }; @@ -355,6 +359,7 @@ private function beginTransaction(Session $session, array $options = []) return $this->connection->beginTransaction($options + [ 'session' => $session->name(), + 'database' => $session->info()['database'] ]); } diff --git a/src/Spanner/Session/Session.php b/src/Spanner/Session/Session.php index c1ae06042f4a..a1a5b380ea08 100644 --- a/src/Spanner/Session/Session.php +++ b/src/Spanner/Session/Session.php @@ -102,7 +102,8 @@ public function exists(array $options = []) { try { $this->connection->getSession($options + [ - 'name' => $this->name() + 'name' => $this->name(), + 'database' => $this->database ]); return true; @@ -120,7 +121,8 @@ public function exists(array $options = []) public function delete(array $options = []) { return $this->connection->deleteSession($options + [ - 'name' => $this->name() + 'name' => $this->name(), + 'database' => $this->database ]); } diff --git a/src/Spanner/composer.json b/src/Spanner/composer.json index dbe67efd14e5..5d92a476cb9e 100644 --- a/src/Spanner/composer.json +++ b/src/Spanner/composer.json @@ -6,7 +6,7 @@ "require": { "ext-grpc": "*", "google/cloud-core": "^1.0", - "google/gax": "^0.8", + "google/gax": "^0.9", "google/proto-client-php": "^0.8" }, "suggest": { diff --git a/tests/unit/Spanner/Connection/GrpcTest.php b/tests/unit/Spanner/Connection/GrpcTest.php index e158a7ebd702..c66298a018e3 100644 --- a/tests/unit/Spanner/Connection/GrpcTest.php +++ b/tests/unit/Spanner/Connection/GrpcTest.php @@ -171,145 +171,162 @@ public function methodProvider() [ 'listInstanceConfigs', ['projectId' => self::PROJECT], - [self::PROJECT, []] + [self::PROJECT, ['userHeaders' => ['google-cloud-resource-prefix' => [self::PROJECT]]]] ], [ 'getInstanceConfig', - ['name' => $configName], - [$configName, []] + ['name' => $configName, 'projectId' => self::PROJECT], + [$configName, ['userHeaders' => ['google-cloud-resource-prefix' => [self::PROJECT]]]] ], [ 'listInstances', ['projectId' => self::PROJECT], - [self::PROJECT, []] + [self::PROJECT, ['userHeaders' => ['google-cloud-resource-prefix' => [self::PROJECT]]]] ], [ 'getInstance', - ['name' => $instanceName], - [$instanceName, []] + ['name' => $instanceName, 'projectId' => self::PROJECT], + [$instanceName, ['userHeaders' => ['google-cloud-resource-prefix' => [self::PROJECT]]]] ], [ 'createInstance', ['projectId' => self::PROJECT, 'instanceId' => $instanceName] + $instanceArgs, - [self::PROJECT, $instanceName, $instance, []], + [self::PROJECT, $instanceName, $instance, ['userHeaders' => ['google-cloud-resource-prefix' => [$instanceName]]]], $lro, null ], [ 'updateInstance', $instanceArgs, - [$instance, $fieldMask, []], + [$instance, $fieldMask, ['userHeaders' => ['google-cloud-resource-prefix' => [$instanceName]]]], $lro, null ], [ 'deleteInstance', ['name' => $instanceName], - [$instanceName, []] + [$instanceName, ['userHeaders' => ['google-cloud-resource-prefix' => [$instanceName]]]] ], [ 'setInstanceIamPolicy', ['resource' => $instanceName, 'policy' => $policy], - [$instanceName, $policy, []] + [$instanceName, $policy, ['userHeaders' => ['google-cloud-resource-prefix' => [$instanceName]]]] ], [ 'getInstanceIamPolicy', ['resource' => $instanceName], - [$instanceName, []] + [$instanceName, ['userHeaders' => ['google-cloud-resource-prefix' => [$instanceName]]]] ], [ 'testInstanceIamPermissions', ['resource' => $instanceName, 'permissions' => $permissions], - [$instanceName, $permissions, []] + [$instanceName, $permissions, ['userHeaders' => ['google-cloud-resource-prefix' => [$instanceName]]]] ], [ 'listDatabases', ['instance' => $instanceName], - [$instanceName, []] + [$instanceName, ['userHeaders' => ['google-cloud-resource-prefix' => [$instanceName]]]] ], [ 'createDatabase', ['instance' => $instanceName, 'createStatement' => $createStmt, 'extraStatements' => []], - [$instanceName, $createStmt, [], []], + [$instanceName, $createStmt, [], ['userHeaders' => ['google-cloud-resource-prefix' => [$instanceName]]]], $lro, null ], [ 'updateDatabaseDdl', ['name' => $databaseName, 'statements' => []], - [$databaseName, [], []], + [$databaseName, [], ['userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]], $lro, null ], [ 'dropDatabase', ['name' => $databaseName], - [$databaseName, []] + [$databaseName, ['userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], [ 'getDatabaseDDL', ['name' => $databaseName], - [$databaseName, []] + [$databaseName, ['userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], [ 'setDatabaseIamPolicy', ['resource' => $databaseName, 'policy' => $policy], - [$databaseName, $policy, []] + [$databaseName, $policy, ['userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], [ 'getDatabaseIamPolicy', ['resource' => $databaseName], - [$databaseName, []] + [$databaseName, ['userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], [ 'testDatabaseIamPermissions', ['resource' => $databaseName, 'permissions' => $permissions], - [$databaseName, $permissions, []] + [$databaseName, $permissions, ['userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], [ 'createSession', ['database' => $databaseName], - [$databaseName, []] + [$databaseName, ['userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], [ 'getSession', - ['name' => $sessionName], - [$sessionName, []] + ['name' => $sessionName, 'database' => $databaseName], + [$sessionName, ['userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], [ 'deleteSession', - ['name' => $sessionName], - [$sessionName, []] + ['name' => $sessionName, 'database' => $databaseName], + [$sessionName, ['userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], [ 'executeStreamingSql', [ 'session' => $sessionName, 'sql' => $sql, - 'transactionId' => $transactionName + 'transactionId' => $transactionName, + 'database' => $databaseName ] + $mapped, [$sessionName, $sql, [ 'transaction' => $transactionSelector, 'params' => $expectedParams, - 'paramTypes' => $expectedParamTypes + 'paramTypes' => $expectedParamTypes, + 'userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]] ]] ], [ 'streamingRead', - ['keySet' => [], 'transactionId' => $transactionName, 'session' => $sessionName, 'table' => $tableName, 'columns' => $columns], - [$sessionName, $tableName, $columns, $keySet, ['transaction' => $transactionSelector]] + [ + 'keySet' => [], + 'transactionId' => $transactionName, + 'session' => $sessionName, + 'table' => $tableName, + 'columns' => $columns, + 'database' => $databaseName, + ], + [$sessionName, $tableName, $columns, $keySet, ['transaction' => $transactionSelector, 'userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], // test read write [ 'beginTransaction', - ['session' => $sessionName, 'transactionOptions' => $readWriteTransactionArgs], - [$sessionName, $readWriteTransactionOptions, []] + [ + 'session' => $sessionName, + 'transactionOptions' => $readWriteTransactionArgs, + 'database' => $databaseName + ], + [$sessionName, $readWriteTransactionOptions, ['userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], // test read only [ 'beginTransaction', - ['session' => $sessionName, 'transactionOptions' => $readOnlyTransactionArgs], - [$sessionName, $readOnlyTransactionOptions, []] + [ + 'session' => $sessionName, + 'transactionOptions' => $readOnlyTransactionArgs, + 'database' => $databaseName + ], + [$sessionName, $readOnlyTransactionOptions, ['userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], // test insert // [ @@ -320,13 +337,22 @@ public function methodProvider() // test single-use transaction [ 'commit', - ['session' => $sessionName, 'mutations' => [], 'singleUseTransaction' => true], - [$sessionName, [], ['singleUseTransaction' => $readWriteTransactionOptions]] + [ + 'session' => $sessionName, + 'mutations' => [], + 'singleUseTransaction' => true, + 'database' => $databaseName + ], + [$sessionName, [], ['singleUseTransaction' => $readWriteTransactionOptions, 'userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], [ 'rollback', - ['session' => $sessionName, 'transactionId' => $transactionName], - [$sessionName, $transactionName, []] + [ + 'session' => $sessionName, + 'transactionId' => $transactionName, + 'database' => $databaseName + ], + [$sessionName, $transactionName, ['userHeaders' => ['google-cloud-resource-prefix' => [$databaseName]]]] ], // ['getOperation'], // ['cancelOperation'], diff --git a/tests/unit/Spanner/OperationTest.php b/tests/unit/Spanner/OperationTest.php index af4f1e8b1da6..aa4eae0a2ac0 100644 --- a/tests/unit/Spanner/OperationTest.php +++ b/tests/unit/Spanner/OperationTest.php @@ -37,6 +37,7 @@ class OperationTest extends \PHPUnit_Framework_TestCase { const SESSION = 'my-session-id'; const TRANSACTION = 'my-transaction-id'; + const DATABASE = 'my-database'; const TIMESTAMP = '2017-01-09T18:05:22.534799Z'; private $connection; @@ -54,6 +55,7 @@ public function setUp() $session = $this->prophesize(Session::class); $session->name()->willReturn(self::SESSION); + $session->info()->willReturn(['database' => self::DATABASE]); $this->session = $session->reveal(); } From 33f743a711c47d66c710a8449ec2a616d99399f3 Mon Sep 17 00:00:00 2001 From: Dave Supplee Date: Thu, 18 May 2017 11:07:42 -0400 Subject: [PATCH 11/11] inverse retry condition --- src/Spanner/Result.php | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/Spanner/Result.php b/src/Spanner/Result.php index 25a9a7d7dfdb..e866d514affc 100644 --- a/src/Spanner/Result.php +++ b/src/Spanner/Result.php @@ -202,20 +202,22 @@ public function rows($format = self::RETURN_ASSOCIATIVE) $generator->next(); } catch (ServiceException $ex) { - if (!$shouldRetry || $ex->getCode() !== Grpc\STATUS_UNAVAILABLE) { - throw $ex; - } + if ($shouldRetry && $ex->getCode() === Grpc\STATUS_UNAVAILABLE) { + $backoff = new ExponentialBackoff($this->retries, function (ServiceException $ex) { + return $ex->getCode() === Grpc\STATUS_UNAVAILABLE + ? true + : false; + }); + + // Attempt to resume using our last stored resume token. If we + // successfully resume, flush the buffer. + $generator = $backoff->execute($call, [$this->resumeToken]); + $bufferedResults = []; - $backoff = new ExponentialBackoff($this->retries, function (ServiceException $ex) { - return $ex->getCode() === Grpc\STATUS_UNAVAILABLE - ? true - : false; - }); + continue; + } - // Attempt to resume using our last stored resume token. If we - // successfully resume, flush the buffer. - $generator = $backoff->execute($call, [$this->resumeToken]); - $bufferedResults = []; + throw $ex; } }