diff --git a/Spanner/src/Connection/Grpc.php b/Spanner/src/Connection/Grpc.php index 8afe9ce3ef2a..3ec1561e78f8 100644 --- a/Spanner/src/Connection/Grpc.php +++ b/Spanner/src/Connection/Grpc.php @@ -1117,6 +1117,10 @@ public function beginTransaction(array $args) $args = $this->addLarHeader($args, $this->larEnabled); } + if (isset($transactionOptions['isolationLevel'])) { + $options->setIsolationLevel($transactionOptions['isolationLevel']); + } + // NOTE: if set for read-only actions, will throw exception if (isset($transactionOptions['excludeTxnFromChangeStreams'])) { $options->setExcludeTxnFromChangeStreams( diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index d36bff3bb1f5..7772f54ab19b 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -37,6 +37,8 @@ use Google\Cloud\Spanner\Session\Session; use Google\Cloud\Spanner\Session\SessionPoolInterface; use Google\Cloud\Spanner\V1\SpannerClient as GapicSpannerClient; +use Google\Cloud\Spanner\V1\TransactionOptions; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use Google\Cloud\Spanner\V1\TypeCode; use Google\Rpc\Code; @@ -192,6 +194,11 @@ class Database */ private $returnInt64AsObject; + /** + * @var int + */ + private int $isolationLevel; + /** * Create an object representing a Database. * @@ -210,6 +217,8 @@ class Database * be returned as a {@see \Google\Cloud\Core\Int64} object for 32 bit * platform compatibility. **Defaults to** false. * @param string $databaseRole The user created database role which creates the session. + * @param int $isolationLevel The level of Isolation for the transactions executed by this Client's instance. + * **Defaults to** IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED */ public function __construct( ConnectionInterface $connection, @@ -221,7 +230,8 @@ public function __construct( ?SessionPoolInterface $sessionPool = null, $returnInt64AsObject = false, array $info = [], - $databaseRole = '' + $databaseRole = '', + $isolationLevel = IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED ) { $this->connection = $connection; $this->instance = $instance; @@ -239,6 +249,7 @@ public function __construct( $this->databaseRole = $databaseRole; $this->directedReadOptions = $instance->directedReadOptions(); $this->returnInt64AsObject = $returnInt64AsObject; + $this->isolationLevel = $isolationLevel; } /** @@ -799,6 +810,8 @@ public function snapshot(array $options = []) * Session labels may be applied using the `labels` key. * @type string $tag A transaction tag. Requests made using this transaction will * use this as the transaction tag. + * @type int $isolationLevel The level of Isolation for the transactions executed by this Client's instance. + * **Defaults to** IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED * } * @return Transaction * @throws \BadMethodCallException If attempting to call this method within @@ -811,7 +824,9 @@ public function transaction(array $options = []) } // Configure readWrite options here. Any nested options for readWrite should be added to this call - $options['transactionOptions'] = $this->configureTransactionOptions($options['transactionOptions'] ?? []); + $options['transactionOptions'] = $this->configureTransactionOptions($options['transactionOptions'] ?? [ + 'isolationLevel' => $options['isolationLevel'] ?? $this->isolationLevel + ]); $session = $this->selectSession( SessionPoolInterface::CONTEXT_READWRITE, @@ -900,6 +915,9 @@ public function transaction(array $options = []) * Session labels may be applied using the `labels` key. * @type string $tag A transaction tag. Requests made using this transaction will * use this as the transaction tag. + * @type array transactionOptions Options for the transaction. + * {@see \Google\Cloud\Spanner\V1\TransactionOptions} + * for available options * } * @return mixed The return value of `$operation`. * @throws \RuntimeException If a transaction is not committed or rolled back. @@ -916,7 +934,11 @@ public function runTransaction(callable $operation, array $options = []) 'maxRetries' => self::MAX_RETRIES, ]; - $options['transactionOptions'] = $this->configureTransactionOptions($options['transactionOptions'] ?? []); + $transactionOptions = (isset($options['transactionOptions'])) ? $options['transactionOptions'] : []; + + $options['transactionOptions'] = $this->configureTransactionOptions([ + 'isolationLevel' => $options['transactionOptions']['isolationLevel'] ?? $this->isolationLevel + ] + $transactionOptions); $session = $this->selectSession( SessionPoolInterface::CONTEXT_READWRITE, @@ -928,6 +950,10 @@ public function runTransaction(callable $operation, array $options = []) // Initial attempt requires to set `begin` options (ILB). if ($attempt === 0) { + if (!isset($options['transactionOptions']['isolationLevel'])) { + $options['transactionOptions']['isolationLevel'] = IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED; + } + // Partitioned DML does not support ILB. if (!isset($options['transactionOptions']['partitionedDml'])) { $options['begin'] = $options['transactionOptions']; @@ -1626,11 +1652,12 @@ public function delete($table, KeySet $keySet, array $options = []) * 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 + * @type bool|array $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`. + * If $begin is an array {@see TransactionOptions} * @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` @@ -1681,6 +1708,10 @@ public function execute($sql, array $options = []) ) = $this->transactionSelector($options); $options = $this->addLarHeader($options, true, $options['transactionContext']); + if (isset($options['transaction']['readWrite'])) { + $options['transaction']['begin']['isolationLevel'] ??= $this->isolationLevel; + } + $options['directedReadOptions'] = $this->configureDirectedReadOptions( $options, $this->directedReadOptions ?? [] @@ -1745,7 +1776,7 @@ public function mutationGroup() * transactions. * } * - * @retur \Generator {@see \Google\Cloud\Spanner\V1\BatchWriteResponse} + * @return \Generator {@see \Google\Cloud\Spanner\V1\BatchWriteResponse} * * @throws ApiException if the remote call fails */ @@ -1889,12 +1920,20 @@ public function batchWrite(array $mutationGroups, array $options = []) * Please note, if using the `priority` setting you may utilize the constants available * on {@see \Google\Cloud\Spanner\V1\RequestOptions\Priority} to set a value. * Please note, the `transactionTag` setting will be ignored as it is not supported for partitioned DML. + * @type array $transactionOptions Transaction options. + * {@see V1\TransactionOptions} * } * @return int The number of rows modified. + * @throws ValidationException */ public function executePartitionedUpdate($statement, array $options = []) { unset($options['requestOptions']['transactionTag']); + + if (isset($options['transactionOptions']['isolationLevel'])) { + throw new ValidationException('Partitioned DML cannot be configured with an isolation level'); + } + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); $beginTransactionOptions = [ @@ -1902,10 +1941,12 @@ public function executePartitionedUpdate($statement, array $options = []) 'partitionedDml' => [], ] ]; + if (isset($options['transactionOptions']['excludeTxnFromChangeStreams'])) { $beginTransactionOptions['transactionOptions']['excludeTxnFromChangeStreams'] = $options['transactionOptions']['excludeTxnFromChangeStreams']; } + $transaction = $this->operation->transaction($session, $beginTransactionOptions); $options = $this->addLarHeader($options); diff --git a/Spanner/src/Instance.php b/Spanner/src/Instance.php index b8fa34e9f4cf..6ef646012a19 100644 --- a/Spanner/src/Instance.php +++ b/Spanner/src/Instance.php @@ -32,6 +32,7 @@ use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Connection\IamInstance; use Google\Cloud\Spanner\Session\SessionPoolInterface; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; /** * Represents a Cloud Spanner instance @@ -126,6 +127,11 @@ class Instance */ private $directedReadOptions; + /** + * @var int + */ + private $isolationLevel; + /** * Create an object representing a Cloud Spanner instance. * @@ -148,6 +154,8 @@ class Instance * {@see \Google\Cloud\Spanner\V1\DirectedReadOptions} * If using the `replicaSelection::type` setting, utilize the constants available in * {@see \Google\Cloud\Spanner\V1\DirectedReadOptions\ReplicaSelection\Type} to set a value. + * @type int $isolationLevel The level of Isolation for the transactions executed by this Client's instance. + * **Defaults to** IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED * } */ public function __construct( @@ -168,6 +176,7 @@ public function __construct( $this->setLroProperties($lroConnection, $lroCallables, $this->name); $this->directedReadOptions = $options['directedReadOptions'] ?? []; + $this->isolationLevel = $options['isolationLevel'] ?? IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED; } /** @@ -515,6 +524,8 @@ public function createDatabaseFromBackup($name, $backup, array $options = []) * @type SessionPoolInterface $sessionPool A pool used to manage * sessions. * @type string $databaseRole The user created database role which creates the session. + * @type int $isolationLevel The IsolationLevel set for the transaction. + * Check {@see IsolationLevel} for more details. * } * @return Database */ @@ -530,7 +541,8 @@ public function database($name, array $options = []) isset($options['sessionPool']) ? $options['sessionPool'] : null, $this->returnInt64AsObject, isset($options['database']) ? $options['database'] : [], - isset($options['databaseRole']) ? $options['databaseRole'] : '' + isset($options['databaseRole']) ? $options['databaseRole'] : '', + $options['isolationLevel'] ?? $this->isolationLevel, ); } diff --git a/Spanner/src/Operation.php b/Spanner/src/Operation.php index 6c8aeecace91..043d504bb6b5 100644 --- a/Spanner/src/Operation.php +++ b/Spanner/src/Operation.php @@ -455,6 +455,7 @@ public function read( * `false`. * @type array $begin The begin transaction options. * [Refer](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#transactionoptions) + * @type int $isolationLevel The level of Isolation for the transactions executed by this Client's instance. * } * @return Transaction */ diff --git a/Spanner/src/SpannerClient.php b/Spanner/src/SpannerClient.php index 3c89a2b174fb..9a7fed5d5faf 100644 --- a/Spanner/src/SpannerClient.php +++ b/Spanner/src/SpannerClient.php @@ -36,6 +36,7 @@ use Google\Cloud\Spanner\Connection\LongRunningConnection; use Google\Cloud\Spanner\Session\SessionPoolInterface; use Google\Cloud\Spanner\V1\SpannerClient as GapicSpannerClient; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use Psr\Cache\CacheItemPoolInterface; use Psr\Http\StreamInterface; @@ -138,6 +139,11 @@ class SpannerClient */ private $directedReadOptions; + /** + * @var int + */ + private $isolationLevel; + /** * Create a Spanner client. Please note that this client requires * [the gRPC extension](https://cloud.google.com/php/grpc). @@ -239,6 +245,8 @@ class SpannerClient * **Defaults to** `true` (enabled). * @type string $universeDomain The expected universe of the credentials. Defaults to * "googleapis.com" + * @type int $isolationLevel The level of Isolation for the transactions executed by this Client's instance. + * **Defaults to** IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED * } * @throws GoogleException If the gRPC extension is not enabled. */ @@ -256,7 +264,8 @@ public function __construct(array $config = []) 'projectIdRequired' => true, 'hasEmulator' => (bool) $emulatorHost, 'emulatorHost' => $emulatorHost, - 'queryOptions' => [] + 'queryOptions' => [], + 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED, ]; if (!empty($config['useDiscreteBackoffs'])) { @@ -317,6 +326,7 @@ public function __construct(array $config = []) ]); $this->directedReadOptions = $config['directedReadOptions'] ?? []; + $this->isolationLevel = $config['isolationLevel']; } /** @@ -596,7 +606,10 @@ public function instance($name, array $instance = []) $name, $this->returnInt64AsObject, $instance, - ['directedReadOptions' => $this->directedReadOptions] + [ + 'directedReadOptions' => $this->directedReadOptions, + 'isolationLevel' => $this->isolationLevel + ] ); } diff --git a/Spanner/src/Transaction.php b/Spanner/src/Transaction.php index 3d740cac45f3..f72e554f3fb1 100644 --- a/Spanner/src/Transaction.php +++ b/Spanner/src/Transaction.php @@ -98,6 +98,8 @@ class Transaction implements TransactionalReadInterface * * @type array $begin The begin Transaction options. * [Refer](https://cloud.google.com/spanner/docs/reference/rpc/google.spanner.v1#transactionoptions) + * @type int $isolationLevel level of Isolation for this transaction instance + * **Defaults to** IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED * } * @param ValueMapper $mapper Consumed internally for properly map mutation data. * @throws \InvalidArgumentException if a tag is specified on a single-use transaction. @@ -229,14 +231,18 @@ public function getCommitStats() * parameter types. Likewise, for structs, use * {@see \Google\Cloud\Spanner\StructType}. * @type array $requestOptions Request options. - * For more information on available options, please see - * [the upstream documentation](https://cloud.google.com/spanner/docs/reference/rest/v1/RequestOptions). - * Please note, if using the `priority` setting you may utilize the constants available - * on {@see \Google\Cloud\Spanner\V1\RequestOptions\Priority} to set a value. - * Please note, the `transactionTag` setting will be ignored as the transaction tag should have already - * been set when creating the transaction. + * For more information on available options, please see + * [the upstream documentation](https://cloud.google.com/spanner/docs/reference/rest/v1/RequestOptions). + * Please note, if using the `priority` setting you may utilize the constants available + * on {@see \Google\Cloud\Spanner\V1\RequestOptions\Priority} to set a value. + * Please note, the `transactionTag` setting will be ignored as the transaction tag should have already + * been set when creating the transaction. + * @type array $transaction a set of Options for a transaction selector. + * For more details on these options please + * {@see https://cloud.google.com/spanner/docs/reference/rest/v1/TransactionSelector} * } * @return int The number of rows modified. + * @throws ValidationException */ public function executeUpdate($sql, array $options = []) { @@ -246,6 +252,20 @@ public function executeUpdate($sql, array $options = []) . ' This option should be set at the transaction level.' ); } + + if (isset($options['transaction']['begin']['isolationLevel']) && empty($this->transactionId)) { + if (isset($options['transaction']['begin']['readOnly'])) { + // isolationLevel can only be used with read/write transactions + throw new ValidationException( + 'The isolation level can only be applied to read/write transactions.' . + 'Single use transactions are not read/write', + ); + } + + // We are planning to create a new transaction, switch to pre allocated. + $this->type = self::TYPE_PRE_ALLOCATED; + } + $options = $this->buildUpdateOptions($options); return $this->operation ->executeUpdate($this->session, $this, $sql, $options); @@ -516,6 +536,11 @@ public function isRetry() private function buildUpdateOptions(array $options): array { unset($options['requestOptions']['transactionTag']); + + if (!empty($options['transaction']['begin'])) { + $options['begin'] = $this->pluck('transaction', $options)['begin']; + } + if (isset($this->tag)) { $options['requestOptions']['transactionTag'] = $this->tag; } diff --git a/Spanner/src/TransactionConfigurationTrait.php b/Spanner/src/TransactionConfigurationTrait.php index a1f3f36776c8..232002f3cdf0 100644 --- a/Spanner/src/TransactionConfigurationTrait.php +++ b/Spanner/src/TransactionConfigurationTrait.php @@ -153,6 +153,10 @@ private function configureTransactionOptions(array $options = []) $transactionOptions['excludeTxnFromChangeStreams'] = $options['excludeTxnFromChangeStreams']; } + if (isset($options['isolationLevel'])) { + $transactionOptions['isolationLevel'] = $options['isolationLevel']; + } + // Allow for proper configuring of the `readLockMode` if it's set as a base or nested option if (isset($options['readLockMode'])) { $transactionOptions['readWrite']['readLockMode'] = $options['readLockMode']; diff --git a/Spanner/tests/Snippet/DatabaseTest.php b/Spanner/tests/Snippet/DatabaseTest.php index 486efd3922e0..0f0ab0493e0b 100644 --- a/Spanner/tests/Snippet/DatabaseTest.php +++ b/Spanner/tests/Snippet/DatabaseTest.php @@ -40,6 +40,7 @@ use Google\Cloud\Spanner\Tests\StubCreationTrait; use Google\Cloud\Spanner\Timestamp; use Google\Cloud\Spanner\Transaction; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -462,7 +463,12 @@ public function testRunTransactionRollback() ->shouldBeCalled(); $this->connection->executeStreamingSql( - Argument::withEntry('transaction', ['begin' => ['readWrite' => []]]) + Argument::withEntry('transaction', [ + 'begin' => [ + 'readWrite' => [], + 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED + ] + ]) ) ->shouldBeCalled() ->willReturn($this->resultGeneratorData([ diff --git a/Spanner/tests/System/TransactionTest.php b/Spanner/tests/System/TransactionTest.php index f30b651aef17..0f5bdc968d2d 100644 --- a/Spanner/tests/System/TransactionTest.php +++ b/Spanner/tests/System/TransactionTest.php @@ -22,6 +22,7 @@ use Google\Cloud\Core\Exception\ServiceException; use Google\Cloud\Spanner\Timestamp; use Google\Cloud\Spanner\V1\DirectedReadOptions\ReplicaSelection\Type as ReplicaType; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use Google\Cloud\Spanner\V1\ReadRequest\LockHint; use Google\Cloud\Spanner\V1\ReadRequest\OrderBy; @@ -411,6 +412,11 @@ public function testRunTransactionILBWithMultipleOperations() 'id' => $id, 'name' => uniqid(self::TESTING_PREFIX), 'birthday' => new Date(new \DateTime) + ], + 'transaction' => [ + 'begin' => [ + 'isolationLevel' => IsolationLevel::REPEATABLE_READ, + ] ] ] ); diff --git a/Spanner/tests/Unit/DatabaseTest.php b/Spanner/tests/Unit/DatabaseTest.php index 563994521450..85c5c042cc37 100644 --- a/Spanner/tests/Unit/DatabaseTest.php +++ b/Spanner/tests/Unit/DatabaseTest.php @@ -18,6 +18,7 @@ namespace Google\Cloud\Spanner\Tests\Unit; use Google\ApiCore\ServerStream; +use Google\ApiCore\ValidationException; use Google\Cloud\Core\Exception\AbortedException; use Google\Cloud\Core\Exception\NotFoundException; use Google\Cloud\Core\Exception\ServerException; @@ -58,6 +59,8 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; +use InvalidArgumentException; use Google\Cloud\Spanner\V1\ReadRequest\LockHint; use Google\Cloud\Spanner\V1\ReadRequest\OrderBy; @@ -82,7 +85,7 @@ class DatabaseTest extends TestCase const TRANSACTION_TAG = 'my-transaction-tag'; const TEST_TABLE_NAME = 'Users'; const TIMESTAMP = '2017-01-09T18:05:22.534799Z'; - const BEGIN_RW_OPTIONS = ['begin' => ['readWrite' => []]]; + const BEGIN_RW_OPTIONS = ['begin' => ['readWrite' => [], 'isolationLevel' => 0]]; private $connection; private $instance; @@ -971,6 +974,38 @@ public function testTransaction() $this->assertInstanceOf(Transaction::class, $t); } + public function testTransactionWithIsolationLevel() + { + $this->connection->beginTransaction(Argument::allOf( + Argument::withEntry('session', $this->session->name()), + Argument::withEntry( + 'database', + DatabaseAdminClient::databaseName( + self::PROJECT, + self::INSTANCE, + self::DATABASE + ) + ), + Argument::withEntry('requestOptions', [ + 'transactionTag' => self::TRANSACTION_TAG, + ]), + Argument::withEntry('transactionOptions', [ + 'readWrite' => [], + 'isolationLevel' => IsolationLevel::REPEATABLE_READ, + ]) + )) + ->shouldBeCalled() + ->willReturn(['id' => self::TRANSACTION]); + + $this->refreshOperation($this->database, $this->connection->reveal()); + + $t = $this->database->transaction([ + 'tag' => self::TRANSACTION_TAG, + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ]); + $this->assertInstanceOf(Transaction::class, $t); + } + public function testTransactionNestedTransaction() { $this->expectException(\BadMethodCallException::class); @@ -1259,6 +1294,34 @@ public function testExecute() $this->assertEquals(10, $rows[0]['ID']); } + public function testExecuteWithIsolationLevel() + { + $sql = 'SELECT * FROM Table'; + + $this->connection->executeStreamingSql(Argument::allOf( + Argument::withEntry('sql', $sql), + Argument::withEntry('headers', ['x-goog-spanner-route-to-leader' => ['true']]), + Argument::withEntry('transaction', [ + 'begin' => [ + 'readWrite' => [], + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ] + ]) + ))->shouldBeCalled()->willReturn($this->resultGenerator()); + + $this->refreshOperation($this->database, $this->connection->reveal()); + + $res = $this->database->execute($sql, [ + 'transactionType' => SessionPoolInterface::CONTEXT_READWRITE, + 'begin' => [ + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ] + ]); + $this->assertInstanceOf(Result::class, $res); + $rows = iterator_to_array($res->rows()); + $this->assertEquals(10, $rows[0]['ID']); + } + public function testExecuteWithSingleSession() { $this->database->___setProperty('sessionPool', null); @@ -1333,6 +1396,22 @@ public function testExecutePartitionedUpdate() $this->assertEquals(1, $res); } + public function testExecutePartitionedUpdateWithIsolationLevelShouldRaise() + { + $sql = 'UPDATE foo SET bar = @bar'; + + $this->refreshOperation($this->database, $this->connection->reveal()); + $this->expectException(ValidationException::class); + + $res = $this->database->executePartitionedUpdate($sql, [ + 'transactionOptions' => [ + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ] + ]); + + $this->assertEquals(1, $res); + } + public function testRead() { $table = 'Table'; @@ -2192,6 +2271,51 @@ public function testBatchWriteWithExcludeTxnFromChangeStreams() ]); } + public function testRunTransactionIsolationLevel() + { + $gapic = $this->prophesize(SpannerClient::class); + + $sessName = SpannerClient::sessionName(self::PROJECT, self::INSTANCE, self::DATABASE, self::SESSION); + $session = new SessionProto(['name' => $sessName]); + $resultSet = new ResultSet(['stats' => new ResultSetStats(['row_count_exact' => 0])]); + $gapic->createSession(Argument::cetera())->shouldBeCalled()->willReturn($session); + $gapic->deleteSession(Argument::cetera())->shouldBeCalled(); + + $sql = 'SELECT example FROM sql_query'; + $stream = $this->prophesize(ServerStream::class); + $stream->readAll()->shouldBeCalledOnce()->willReturn([$resultSet]); + $gapic->executeStreamingSql($sessName, $sql, Argument::that(function (array $options) { + $this->assertArrayHasKey('transaction', $options); + $this->assertNotNull($transactionOptions = $options['transaction']->getBegin()); + $this->assertEquals(IsolationLevel::REPEATABLE_READ, $transactionOptions->getIsolationLevel()); + return true; + })) + ->shouldBeCalledOnce() + ->willReturn($stream->reveal()); + + $database = new Database( + new Grpc(['gapicSpannerClient' => $gapic->reveal()]), + $this->instance, + $this->lro->reveal(), + $this->lroCallables, + self::PROJECT, + self::DATABASE + ); + + $database->runTransaction( + function (Transaction $t) use ($sql) { + // Run a fake query + $t->executeUpdate($sql); + + // Simulate calling Transaction::commmit() + $prop = new \ReflectionProperty($t, 'state'); + $prop->setAccessible(true); + $prop->setValue($t, Transaction::STATE_COMMITTED); + }, + ['transactionOptions' => ['isolationLevel' => IsolationLevel::REPEATABLE_READ]] + ); + } + public function testRunTransactionWithReadLockMode() { $expectedReadLockMode = ReadLockMode::OPTIMISTIC; diff --git a/Spanner/tests/Unit/OperationTest.php b/Spanner/tests/Unit/OperationTest.php index 40a2c4c315ea..0f8a3def30c7 100644 --- a/Spanner/tests/Unit/OperationTest.php +++ b/Spanner/tests/Unit/OperationTest.php @@ -41,6 +41,7 @@ use Google\Cloud\Spanner\V1\Transaction as TransactionProto; use Google\Cloud\Spanner\V1\TransactionOptions; use Google\Cloud\Spanner\V1\TransactionOptions\ReadWrite\ReadLockMode as ReadLockMode; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -343,6 +344,27 @@ public function testTransaction() $this->assertEquals(self::TRANSACTION, $t->id()); } + public function testTransactioWithIsolationLevel() + { + $this->connection->beginTransaction(Argument::allOf( + Argument::withEntry('database', self::DATABASE), + Argument::withEntry('session', $this->session->name()), + Argument::withEntry('isolationLevel', IsolationLevel::REPEATABLE_READ) + )) + ->shouldBeCalled() + ->willReturn(['id' => self::TRANSACTION]); + + $this->operation->___setProperty('connection', $this->connection->reveal()); + + $options = [ + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ]; + + $t = $this->operation->transaction($this->session, $options); + $this->assertInstanceOf(Transaction::class, $t); + $this->assertEquals(self::TRANSACTION, $t->id()); + } + public function testTransactionNoTag() { $this->connection->beginTransaction(Argument::allOf( diff --git a/Spanner/tests/Unit/SpannerClientTest.php b/Spanner/tests/Unit/SpannerClientTest.php index beeb17033339..c3751f707ae0 100644 --- a/Spanner/tests/Unit/SpannerClientTest.php +++ b/Spanner/tests/Unit/SpannerClientTest.php @@ -41,10 +41,12 @@ use Google\Cloud\Spanner\SpannerClient; use Google\Cloud\Spanner\Tests\StubCreationTrait; use Google\Cloud\Spanner\Timestamp; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use ReflectionClass; /** * @group spanner @@ -81,7 +83,7 @@ public function setUp(): void $this->client = TestHelpers::stub(SpannerClient::class, [ [ 'projectId' => self::PROJECT, - 'directedReadOptions' => $this->directedReadOptionsIncludeReplicas + 'directedReadOptions' => $this->directedReadOptionsIncludeReplicas, ] ]); } @@ -473,4 +475,76 @@ public function testSpannerClientWithDirectedRead() $this->directedReadOptionsIncludeReplicas ); } + + public function testClientPassesIsolationLevel() + { + /** @var SpannerClient $client */ + $client = new SpannerClient([ + 'projectId' => self::PROJECT, + 'directedReadOptions' => $this->directedReadOptionsIncludeReplicas, + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ]); + + $reflectedClient = new ReflectionClass($client); + $property = $reflectedClient->getProperty('isolationLevel'); + $property->setAccessible(true); + $this->assertEquals( + IsolationLevel::REPEATABLE_READ, + $property->getValue($client) + ); + + $instance = $client->instance('test'); + $reflectedInstance = new ReflectionClass($instance); + $property = $reflectedInstance->getProperty('isolationLevel'); + $property->setAccessible(true); + $this->assertEquals( + IsolationLevel::REPEATABLE_READ, + $property->getValue($instance) + ); + + $database = $instance->database('test'); + $reflectedDb = new ReflectionClass($database); + $property = $reflectedDb->getProperty('isolationLevel'); + $property->setAccessible(true); + $this->assertEquals( + IsolationLevel::REPEATABLE_READ, + $property->getValue($database) + ); + } + + public function testTransactionHasCorrectIsolationLevel() + { + /** @var SpannerClient $client */ + $client = new SpannerClient([ + 'projectId' => self::PROJECT, + 'directedReadOptions' => $this->directedReadOptionsIncludeReplicas, + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ]); + + $reflectedClient = new ReflectionClass($client); + $property = $reflectedClient->getProperty('isolationLevel'); + $property->setAccessible(true); + $this->assertEquals( + IsolationLevel::REPEATABLE_READ, + $property->getValue($client) + ); + + $instance = $client->instance('test'); + $reflectedInstance = new ReflectionClass($instance); + $property = $reflectedInstance->getProperty('isolationLevel'); + $property->setAccessible(true); + $this->assertEquals( + IsolationLevel::REPEATABLE_READ, + $property->getValue($instance) + ); + + $database = $instance->database('test'); + $reflectedDb = new ReflectionClass($database); + $property = $reflectedDb->getProperty('isolationLevel'); + $property->setAccessible(true); + $this->assertEquals( + IsolationLevel::REPEATABLE_READ, + $property->getValue($database) + ); + } } diff --git a/Spanner/tests/Unit/TransactionTest.php b/Spanner/tests/Unit/TransactionTest.php index 1563a9c9c9b6..23e620889a31 100644 --- a/Spanner/tests/Unit/TransactionTest.php +++ b/Spanner/tests/Unit/TransactionTest.php @@ -33,6 +33,7 @@ use Google\Cloud\Spanner\Tests\StubCreationTrait; use Google\Cloud\Spanner\Timestamp; use Google\Cloud\Spanner\Transaction; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -642,6 +643,64 @@ public function testIsRetryTrue() $this->assertTrue($transaction->isRetry()); } + public function testExecuteUpdateWithIsolationLevel() + { + $sql = 'UPDATE foo SET bar = @bar'; + $this->connection->executeStreamingSql(Argument::allOf( + Argument::withEntry('sql', $sql), + Argument::withEntry('transaction', [ + 'begin' => [ + 'readWrite' => [], + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ] + ]), + Argument::withEntry('headers', ['x-goog-spanner-route-to-leader' => ['true']]) + ))->shouldBeCalled()->willReturn($this->resultGenerator(true)); + + $this->refreshOperation($this->transaction, $this->connection->reveal()); + + // Transaction without an ID to be able to use `begin` + $transaction = new Transaction( + $this->operation, + $this->session + ); + + $options = [ + 'transaction' => [ + 'begin' => [ + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ] + ] + ]; + + $res = $transaction->executeUpdate($sql, $options); + + $this->assertEquals(1, $res); + } + + public function testSingleUseWithIsolationLevelThrowsAnExceptionOnReadOnly() + { + $this->expectException(ValidationException::class); + + $sql = 'UPDATE foo SET bar = @bar'; + + $this->refreshOperation($this->transaction, $this->connection->reveal()); + + $options = [ + 'transaction' => [ + 'begin' => [ + 'isolationLevel' => IsolationLevel::REPEATABLE_READ, + 'readOnly' => [] + ] + ] + ]; + + $res = $this->singleUseTransaction->executeUpdate($sql, $options); + + $this->assertEquals(1, $res); + } + + // ******* // Helpers diff --git a/Spanner/tests/Unit/TransactionTypeTest.php b/Spanner/tests/Unit/TransactionTypeTest.php index d228794c863b..210d07a04d73 100644 --- a/Spanner/tests/Unit/TransactionTypeTest.php +++ b/Spanner/tests/Unit/TransactionTypeTest.php @@ -34,6 +34,7 @@ use Google\Cloud\Spanner\Timestamp; use Google\Cloud\Spanner\Transaction; use Google\Cloud\Spanner\V1\SpannerClient; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -84,7 +85,8 @@ public function testDatabaseRunTransactionPreAllocate() $this->connection->beginTransaction(Argument::allOf( Argument::withEntry('singleUse', false), Argument::withEntry('transactionOptions', [ - 'readWrite' => [] + 'readWrite' => [], + 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED ]) ))->shouldBeCalledTimes(1)->willReturn(['id' => self::TRANSACTION]); @@ -123,7 +125,8 @@ public function testDatabaseTransactionPreAllocate() $this->connection->beginTransaction(Argument::allOf( Argument::withEntry('singleUse', false), Argument::withEntry('transactionOptions', [ - 'readWrite' => [] + 'readWrite' => [], + 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED, ]) ))->shouldBeCalledTimes(1)->willReturn(['id' => self::TRANSACTION]); @@ -740,7 +743,8 @@ public function testDatabaseReadBeginReadWrite($chunks) public function testTransactionPreAllocatedRollback() { $this->connection->beginTransaction(Argument::withEntry('transactionOptions', [ - 'readWrite' => [] + 'readWrite' => [], + 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED ]))->shouldBeCalledTimes(1)->willReturn(['id' => self::TRANSACTION]); $sess = SpannerClient::sessionName(