From dfcd5e9b6a09d52cab26c4357eaed406ea27d5bf Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Tue, 15 Apr 2025 21:42:51 +0000 Subject: [PATCH 01/24] Pass the isolationLevel from the SpannerClient down to operation --- Spanner/src/Connection/Grpc.php | 2 ++ Spanner/src/Database.php | 16 ++++++++++++++-- Spanner/src/Instance.php | 11 ++++++++++- Spanner/src/SpannerClient.php | 17 +++++++++++++++-- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/Spanner/src/Connection/Grpc.php b/Spanner/src/Connection/Grpc.php index a70de619bf69..182f68fc23e7 100644 --- a/Spanner/src/Connection/Grpc.php +++ b/Spanner/src/Connection/Grpc.php @@ -1111,6 +1111,8 @@ public function beginTransaction(array $args) $pdml = new PartitionedDml(); $options->setPartitionedDml($pdml); $args = $this->addLarHeader($args, $this->larEnabled); + } elseif (isset($transactionOptions['isolationLevel'])) { + $options->setIsolationLevel($transactionOptions['isolationLevel']); } // NOTE: if set for read-only actions, will throw exception diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index 977aecc28207..4c2afa5a208a 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -37,6 +37,7 @@ 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\IsolationLevel; use Google\Cloud\Spanner\V1\TypeCode; use Google\Rpc\Code; @@ -191,6 +192,11 @@ class Database */ private $returnInt64AsObject; + /** + * @var int + */ + private int $isolationLevel; + /** * Create an object representing a Database. * @@ -209,6 +215,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, @@ -220,7 +228,8 @@ public function __construct( ?SessionPoolInterface $sessionPool = null, $returnInt64AsObject = false, array $info = [], - $databaseRole = '' + $databaseRole = '', + $isolationLevel = IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED ) { $this->connection = $connection; $this->instance = $instance; @@ -238,6 +247,7 @@ public function __construct( $this->databaseRole = $databaseRole; $this->directedReadOptions = $instance->directedReadOptions(); $this->returnInt64AsObject = $returnInt64AsObject; + $this->isolationLevel = $isolationLevel; } /** @@ -812,7 +822,9 @@ public function transaction(array $options = []) } // There isn't anything configurable here. - $options['transactionOptions'] = $this->configureTransactionOptions(); + $options['transactionOptions'] = $this->configureTransactionOptions([ + 'isolationLevel' => $this->isolationLevel + ]); $session = $this->selectSession( SessionPoolInterface::CONTEXT_READWRITE, diff --git a/Spanner/src/Instance.php b/Spanner/src/Instance.php index b8fa34e9f4cf..43accfdf10e9 100644 --- a/Spanner/src/Instance.php +++ b/Spanner/src/Instance.php @@ -126,6 +126,11 @@ class Instance */ private $directedReadOptions; + /** + * @var int + */ + private $isolationLevel; + /** * Create an object representing a Cloud Spanner instance. * @@ -148,6 +153,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. + * @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( @@ -168,6 +175,7 @@ public function __construct( $this->setLroProperties($lroConnection, $lroCallables, $this->name); $this->directedReadOptions = $options['directedReadOptions'] ?? []; + $this->isolationLevel = $options['isolationLevel']; } /** @@ -530,7 +538,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'] : '', + isset($options['isolationLevel']) ? $options['isolationLevel'] : $this->isolationLevel, ); } diff --git a/Spanner/src/SpannerClient.php b/Spanner/src/SpannerClient.php index 272e892e36a1..ed00f7fd2584 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). @@ -201,6 +207,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. */ @@ -218,7 +226,8 @@ public function __construct(array $config = []) 'projectIdRequired' => true, 'hasEmulator' => (bool) $emulatorHost, 'emulatorHost' => $emulatorHost, - 'queryOptions' => [] + 'queryOptions' => [], + 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED, ]; if (!empty($config['useDiscreteBackoffs'])) { @@ -279,6 +288,7 @@ public function __construct(array $config = []) ]); $this->directedReadOptions = $config['directedReadOptions'] ?? []; + $this->isolationLevel = $config['isolationLevel']; } /** @@ -558,7 +568,10 @@ public function instance($name, array $instance = []) $name, $this->returnInt64AsObject, $instance, - ['directedReadOptions' => $this->directedReadOptions] + [ + 'directedReadOptions' => $this->directedReadOptions, + 'isolationLevel' => $this->isolationLevel + ] ); } From 3d096e4426790f28319268930ed50eda599eca4f Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Tue, 15 Apr 2025 23:24:24 +0000 Subject: [PATCH 02/24] Add support for isolationLevel to the Database class --- Spanner/src/Connection/Grpc.php | 4 +++- Spanner/src/Database.php | 9 +++++++-- Spanner/src/Operation.php | 6 +++++- Spanner/src/Transaction.php | 2 ++ Spanner/src/TransactionConfigurationTrait.php | 4 ++++ 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Spanner/src/Connection/Grpc.php b/Spanner/src/Connection/Grpc.php index 182f68fc23e7..a5395dca5201 100644 --- a/Spanner/src/Connection/Grpc.php +++ b/Spanner/src/Connection/Grpc.php @@ -1111,7 +1111,9 @@ public function beginTransaction(array $args) $pdml = new PartitionedDml(); $options->setPartitionedDml($pdml); $args = $this->addLarHeader($args, $this->larEnabled); - } elseif (isset($transactionOptions['isolationLevel'])) { + } + + if (isset($transactionOptions['isolationLevel'])) { $options->setIsolationLevel($transactionOptions['isolationLevel']); } diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index 4c2afa5a208a..23edf2943666 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -810,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 @@ -821,9 +823,8 @@ public function transaction(array $options = []) throw new \BadMethodCallException('Nested transactions are not supported by this client.'); } - // There isn't anything configurable here. $options['transactionOptions'] = $this->configureTransactionOptions([ - 'isolationLevel' => $this->isolationLevel + 'isolationLevel' => $options['isolationLevel'] ?? $this->isolationLevel ]); $session = $this->selectSession( @@ -948,6 +949,10 @@ public function runTransaction(callable $operation, array $options = []) if (!isset($options['transactionOptions']['partitionedDml'])) { $options['begin'] = $options['transactionOptions']; } + + if (!isset($options['transactionOptions']['isolationLevel'])) { + $options['transactionOptions']['isolationLevel'] = IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED; + } } else { $options['isRetry'] = true; } diff --git a/Spanner/src/Operation.php b/Spanner/src/Operation.php index 6c8aeecace91..e9f4d9748994 100644 --- a/Spanner/src/Operation.php +++ b/Spanner/src/Operation.php @@ -25,6 +25,7 @@ use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Session\Session; use Google\Cloud\Spanner\V1\SpannerClient as GapicSpannerClient; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use Google\Rpc\Code; use InvalidArgumentException; @@ -455,6 +456,8 @@ 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. + * **Defaults to** IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED * } * @return Transaction */ @@ -463,7 +466,8 @@ public function transaction(Session $session, array $options = []) $options += [ 'singleUse' => false, 'isRetry' => false, - 'requestOptions' => [] + 'requestOptions' => [], + 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED ]; $transactionTag = $this->pluck('tag', $options, false); if (isset($transactionTag)) { diff --git a/Spanner/src/Transaction.php b/Spanner/src/Transaction.php index 3d740cac45f3..b7a1577e5777 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. diff --git a/Spanner/src/TransactionConfigurationTrait.php b/Spanner/src/TransactionConfigurationTrait.php index 31dfa76809c7..f0b6219174bf 100644 --- a/Spanner/src/TransactionConfigurationTrait.php +++ b/Spanner/src/TransactionConfigurationTrait.php @@ -152,6 +152,10 @@ private function configureTransactionOptions(array $options = []) $transactionOptions['excludeTxnFromChangeStreams'] = $options['excludeTxnFromChangeStreams']; } + if (isset($options['isolationLevel'])) { + $transactionOptions['isolationLevel'] = $options['isolationLevel']; + } + return $transactionOptions; } From a5bb0d4139da220426474d15f3e6f52821967e04 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Tue, 15 Apr 2025 23:26:15 +0000 Subject: [PATCH 03/24] Update the Instance class --- Spanner/src/Instance.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Spanner/src/Instance.php b/Spanner/src/Instance.php index 43accfdf10e9..97fa56920444 100644 --- a/Spanner/src/Instance.php +++ b/Spanner/src/Instance.php @@ -539,7 +539,7 @@ public function database($name, array $options = []) $this->returnInt64AsObject, isset($options['database']) ? $options['database'] : [], isset($options['databaseRole']) ? $options['databaseRole'] : '', - isset($options['isolationLevel']) ? $options['isolationLevel'] : $this->isolationLevel, + $options['isolationLevel'] ?? $this->isolationLevel, ); } From e37fbcaaabaac209056fa390b3f9e839c82759d9 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Wed, 16 Apr 2025 21:40:43 +0000 Subject: [PATCH 04/24] Add configuration for Database --- Spanner/src/Database.php | 21 +++++++++++++++++---- Spanner/src/Instance.php | 3 ++- Spanner/src/Transaction.php | 12 ++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index 23edf2943666..772087ac9cbd 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -1647,11 +1647,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` @@ -1696,12 +1697,16 @@ public function execute($sql, array $options = []) $this->pluck('sessionOptions', $options, false) ?: [] ); - list( + [ $options['transaction'], $options['transactionContext'] - ) = $this->transactionSelector($options); + ] = $this->transactionSelector($options); $options = $this->addLarHeader($options, true, $options['transactionContext']); + if ($options['transactionType'] === SessionPoolInterface::CONTEXT_READWRITE && $this->isolationLevel) { + $options['transaction']['begin']['isolationLevel'] ??= $this->isolationLevel; + } + $options['directedReadOptions'] = $this->configureDirectedReadOptions( $options, $this->directedReadOptions ?? [] @@ -1766,7 +1771,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 */ @@ -1910,6 +1915,8 @@ 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. */ @@ -1923,10 +1930,16 @@ public function executePartitionedUpdate($statement, array $options = []) 'partitionedDml' => [], ] ]; + if (isset($options['transactionOptions']['excludeTxnFromChangeStreams'])) { $beginTransactionOptions['transactionOptions']['excludeTxnFromChangeStreams'] = $options['transactionOptions']['excludeTxnFromChangeStreams']; } + + if (isset($options['transactionOptions']['isolationLevel']) || $this->isolationLevel) { + $options['transactionOptions']['isolationLevel'] ??= $this->isolationLevel; + } + $transaction = $this->operation->transaction($session, $beginTransactionOptions); $options = $this->addLarHeader($options); diff --git a/Spanner/src/Instance.php b/Spanner/src/Instance.php index 97fa56920444..80fdd9a53177 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 @@ -175,7 +176,7 @@ public function __construct( $this->setLroProperties($lroConnection, $lroCallables, $this->name); $this->directedReadOptions = $options['directedReadOptions'] ?? []; - $this->isolationLevel = $options['isolationLevel']; + $this->isolationLevel = $options['isolationLevel'] ?? IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED; } /** diff --git a/Spanner/src/Transaction.php b/Spanner/src/Transaction.php index b7a1577e5777..6af89ce71f48 100644 --- a/Spanner/src/Transaction.php +++ b/Spanner/src/Transaction.php @@ -248,6 +248,18 @@ public function executeUpdate($sql, array $options = []) . ' This option should be set at the transaction level.' ); } + + if ( + $this->type() === self::TYPE_SINGLE_USE && + isset($options['transaction']['begin']['isolationLevel']) || + isset($options['transaction']['single_use']['isolationLevel']) + ) { + throw new ValidationException( + 'The isolation level can only be applied to read/write transactions.'. + 'Single use transactions are not read/write', + ); + } + $options = $this->buildUpdateOptions($options); return $this->operation ->executeUpdate($this->session, $this, $sql, $options); From 98c856ff33159be0009ea58736e70da40f617849 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Thu, 17 Apr 2025 20:27:41 +0000 Subject: [PATCH 05/24] Add tests for isolationLevel --- Spanner/src/Database.php | 9 +- Spanner/tests/System/TransactionTest.php | 6 + Spanner/tests/Unit/DatabaseTest.php | 140 +++++++++++++++++++++ Spanner/tests/Unit/TransactionTypeTest.php | 10 +- 4 files changed, 158 insertions(+), 7 deletions(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index 772087ac9cbd..96b64c974f39 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -1697,13 +1697,13 @@ public function execute($sql, array $options = []) $this->pluck('sessionOptions', $options, false) ?: [] ); - [ + list( $options['transaction'], $options['transactionContext'] - ] = $this->transactionSelector($options); + ) = $this->transactionSelector($options); $options = $this->addLarHeader($options, true, $options['transactionContext']); - if ($options['transactionType'] === SessionPoolInterface::CONTEXT_READWRITE && $this->isolationLevel) { + if (isset($options['transaction']['readWrite'])) { $options['transaction']['begin']['isolationLevel'] ??= $this->isolationLevel; } @@ -1937,7 +1937,8 @@ public function executePartitionedUpdate($statement, array $options = []) } if (isset($options['transactionOptions']['isolationLevel']) || $this->isolationLevel) { - $options['transactionOptions']['isolationLevel'] ??= $this->isolationLevel; + $beginTransactionOptions['transactionOptions']['isolationLevel'] = + $options['transactionOptions']['isolationLevel'] ?? $this->isolationLevel; } $transaction = $this->operation->transaction($session, $beginTransactionOptions); diff --git a/Spanner/tests/System/TransactionTest.php b/Spanner/tests/System/TransactionTest.php index 99b9931cb025..02f3558f2185 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; /** * @group spanner @@ -409,6 +410,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 a63c361fc6a8..78b37d67c0f6 100644 --- a/Spanner/tests/Unit/DatabaseTest.php +++ b/Spanner/tests/Unit/DatabaseTest.php @@ -57,6 +57,7 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Google\Cloud\Core\Exception\ServiceException; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; /** * @group spanner @@ -970,6 +971,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); @@ -1258,6 +1291,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); @@ -1332,6 +1393,40 @@ public function testExecutePartitionedUpdate() $this->assertEquals(1, $res); } + public function testExecutePartitionedUpdateWithIsolationLevel() + { + $sql = 'UPDATE foo SET bar = @bar'; + $this->connection->beginTransaction(Argument::allOf( + Argument::withEntry('transactionOptions', [ + 'partitionedDml' => [], + 'isolationLevel' => IsolationLevel::REPEATABLE_READ, + ]), + Argument::withEntry('singleUse', false) + ))->shouldBeCalled()->willReturn([ + 'id' => self::TRANSACTION + ]); + + $this->connection->executeStreamingSql(Argument::allOf( + Argument::withEntry('sql', $sql), + Argument::withEntry('transaction', [ + 'id' => self::TRANSACTION, + ]), + Argument::withEntry('transactionOptions', [ + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ]), + Argument::withEntry('headers', ['x-goog-spanner-route-to-leader' => ['true']]) + ))->shouldBeCalled()->willReturn($this->resultGenerator(true)); + + $this->refreshOperation($this->database, $this->connection->reveal()); + $res = $this->database->executePartitionedUpdate($sql, [ + 'transactionOptions' => [ + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ] + ]); + + $this->assertEquals(1, $res); + } + public function testRead() { $table = 'Table'; @@ -2139,6 +2234,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]] + ); + } + private function createStreamingAPIArgs() { $row = ['id' => 1]; 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( From d1432158622c8835d718035411b5f0d52ff8851a Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Thu, 17 Apr 2025 20:34:55 +0000 Subject: [PATCH 06/24] Fix style issues --- Spanner/src/Transaction.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Spanner/src/Transaction.php b/Spanner/src/Transaction.php index 6af89ce71f48..dd7d3d76d4a0 100644 --- a/Spanner/src/Transaction.php +++ b/Spanner/src/Transaction.php @@ -249,13 +249,12 @@ public function executeUpdate($sql, array $options = []) ); } - if ( - $this->type() === self::TYPE_SINGLE_USE && + if ($this->type() === self::TYPE_SINGLE_USE && isset($options['transaction']['begin']['isolationLevel']) || isset($options['transaction']['single_use']['isolationLevel']) ) { throw new ValidationException( - 'The isolation level can only be applied to read/write transactions.'. + 'The isolation level can only be applied to read/write transactions.' . 'Single use transactions are not read/write', ); } From a3f2c86c17980da72ed7c11ee466b962e2f30a97 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Thu, 17 Apr 2025 21:47:19 +0000 Subject: [PATCH 07/24] Update the executePartitionUpdate method --- Spanner/src/Database.php | 11 ++++++----- Spanner/tests/Unit/DatabaseTest.php | 26 +++++--------------------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index 96b64c974f39..ac096d0a9cfa 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -40,6 +40,7 @@ use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use Google\Cloud\Spanner\V1\TypeCode; use Google\Rpc\Code; +use InvalidArgumentException; /** * Represents a Cloud Spanner Database. @@ -1923,6 +1924,11 @@ public function batchWrite(array $mutationGroups, array $options = []) public function executePartitionedUpdate($statement, array $options = []) { unset($options['requestOptions']['transactionTag']); + + if (isset($options['transactionOptions']['isolationLevel'])) { + throw new InvalidArgumentException('Partitioned DML cannot be configured with an isolation level'); + } + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); $beginTransactionOptions = [ @@ -1936,11 +1942,6 @@ public function executePartitionedUpdate($statement, array $options = []) $options['transactionOptions']['excludeTxnFromChangeStreams']; } - if (isset($options['transactionOptions']['isolationLevel']) || $this->isolationLevel) { - $beginTransactionOptions['transactionOptions']['isolationLevel'] = - $options['transactionOptions']['isolationLevel'] ?? $this->isolationLevel; - } - $transaction = $this->operation->transaction($session, $beginTransactionOptions); $options = $this->addLarHeader($options); diff --git a/Spanner/tests/Unit/DatabaseTest.php b/Spanner/tests/Unit/DatabaseTest.php index 78b37d67c0f6..dabbb3a4758b 100644 --- a/Spanner/tests/Unit/DatabaseTest.php +++ b/Spanner/tests/Unit/DatabaseTest.php @@ -58,6 +58,8 @@ use Prophecy\PhpUnit\ProphecyTrait; use Google\Cloud\Core\Exception\ServiceException; use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; +use InvalidArgumentException; +use Kreait\Firebase\Exception\Messaging\InvalidArgument; /** * @group spanner @@ -1393,31 +1395,13 @@ public function testExecutePartitionedUpdate() $this->assertEquals(1, $res); } - public function testExecutePartitionedUpdateWithIsolationLevel() + public function testExecutePartitionedUpdateWithIsolationLevelShouldRaise() { $sql = 'UPDATE foo SET bar = @bar'; - $this->connection->beginTransaction(Argument::allOf( - Argument::withEntry('transactionOptions', [ - 'partitionedDml' => [], - 'isolationLevel' => IsolationLevel::REPEATABLE_READ, - ]), - Argument::withEntry('singleUse', false) - ))->shouldBeCalled()->willReturn([ - 'id' => self::TRANSACTION - ]); - - $this->connection->executeStreamingSql(Argument::allOf( - Argument::withEntry('sql', $sql), - Argument::withEntry('transaction', [ - 'id' => self::TRANSACTION, - ]), - Argument::withEntry('transactionOptions', [ - 'isolationLevel' => IsolationLevel::REPEATABLE_READ - ]), - Argument::withEntry('headers', ['x-goog-spanner-route-to-leader' => ['true']]) - ))->shouldBeCalled()->willReturn($this->resultGenerator(true)); $this->refreshOperation($this->database, $this->connection->reveal()); + $this->expectException(InvalidArgumentException::class); + $res = $this->database->executePartitionedUpdate($sql, [ 'transactionOptions' => [ 'isolationLevel' => IsolationLevel::REPEATABLE_READ From 095b631d0b17ced8890dc3e8d27f9f76d41e0123 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Mon, 2 Jun 2025 21:41:17 +0000 Subject: [PATCH 08/24] Perform a transaction with the Client's isolation level when set --- Spanner/src/Database.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index ac096d0a9cfa..2b8d7305b27a 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -934,7 +934,9 @@ public function runTransaction(callable $operation, array $options = []) ]; // There isn't anything configurable here. - $options['transactionOptions'] = $this->configureTransactionOptions($options['transactionOptions'] ?? []); + $options['transactionOptions'] = $this->configureTransactionOptions([ + 'isolationLevel' => $options['isolationLevel'] ?? $this->isolationLevel + ]); $session = $this->selectSession( SessionPoolInterface::CONTEXT_READWRITE, From 5823a912ce1d156f6a16364680a9f13f1dc87490 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Tue, 15 Apr 2025 21:42:51 +0000 Subject: [PATCH 09/24] Pass the isolationLevel from the SpannerClient down to operation --- Spanner/src/Connection/Grpc.php | 2 ++ Spanner/src/Database.php | 16 ++++++++++++++-- Spanner/src/Instance.php | 11 ++++++++++- Spanner/src/SpannerClient.php | 17 +++++++++++++++-- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/Spanner/src/Connection/Grpc.php b/Spanner/src/Connection/Grpc.php index a70de619bf69..182f68fc23e7 100644 --- a/Spanner/src/Connection/Grpc.php +++ b/Spanner/src/Connection/Grpc.php @@ -1111,6 +1111,8 @@ public function beginTransaction(array $args) $pdml = new PartitionedDml(); $options->setPartitionedDml($pdml); $args = $this->addLarHeader($args, $this->larEnabled); + } elseif (isset($transactionOptions['isolationLevel'])) { + $options->setIsolationLevel($transactionOptions['isolationLevel']); } // NOTE: if set for read-only actions, will throw exception diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index 583a5e9c2aee..257b1ccfafec 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -37,6 +37,7 @@ 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\IsolationLevel; use Google\Cloud\Spanner\V1\TypeCode; use Google\Rpc\Code; @@ -192,6 +193,11 @@ class Database */ private $returnInt64AsObject; + /** + * @var int + */ + private int $isolationLevel; + /** * Create an object representing a Database. * @@ -210,6 +216,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 +229,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 +248,7 @@ public function __construct( $this->databaseRole = $databaseRole; $this->directedReadOptions = $instance->directedReadOptions(); $this->returnInt64AsObject = $returnInt64AsObject; + $this->isolationLevel = $isolationLevel; } /** @@ -813,7 +823,9 @@ public function transaction(array $options = []) } // There isn't anything configurable here. - $options['transactionOptions'] = $this->configureTransactionOptions(); + $options['transactionOptions'] = $this->configureTransactionOptions([ + 'isolationLevel' => $this->isolationLevel + ]); $session = $this->selectSession( SessionPoolInterface::CONTEXT_READWRITE, diff --git a/Spanner/src/Instance.php b/Spanner/src/Instance.php index b8fa34e9f4cf..43accfdf10e9 100644 --- a/Spanner/src/Instance.php +++ b/Spanner/src/Instance.php @@ -126,6 +126,11 @@ class Instance */ private $directedReadOptions; + /** + * @var int + */ + private $isolationLevel; + /** * Create an object representing a Cloud Spanner instance. * @@ -148,6 +153,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. + * @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( @@ -168,6 +175,7 @@ public function __construct( $this->setLroProperties($lroConnection, $lroCallables, $this->name); $this->directedReadOptions = $options['directedReadOptions'] ?? []; + $this->isolationLevel = $options['isolationLevel']; } /** @@ -530,7 +538,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'] : '', + isset($options['isolationLevel']) ? $options['isolationLevel'] : $this->isolationLevel, ); } diff --git a/Spanner/src/SpannerClient.php b/Spanner/src/SpannerClient.php index 64a969177cde..42d39c14d0e6 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). @@ -201,6 +207,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. */ @@ -218,7 +226,8 @@ public function __construct(array $config = []) 'projectIdRequired' => true, 'hasEmulator' => (bool) $emulatorHost, 'emulatorHost' => $emulatorHost, - 'queryOptions' => [] + 'queryOptions' => [], + 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED, ]; if (!empty($config['useDiscreteBackoffs'])) { @@ -279,6 +288,7 @@ public function __construct(array $config = []) ]); $this->directedReadOptions = $config['directedReadOptions'] ?? []; + $this->isolationLevel = $config['isolationLevel']; } /** @@ -558,7 +568,10 @@ public function instance($name, array $instance = []) $name, $this->returnInt64AsObject, $instance, - ['directedReadOptions' => $this->directedReadOptions] + [ + 'directedReadOptions' => $this->directedReadOptions, + 'isolationLevel' => $this->isolationLevel + ] ); } From 76f0512ff31129c8bdcc2a91be004cef93fa120c Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Tue, 15 Apr 2025 23:24:24 +0000 Subject: [PATCH 10/24] Add support for isolationLevel to the Database class --- Spanner/src/Connection/Grpc.php | 4 +++- Spanner/src/Database.php | 9 +++++++-- Spanner/src/Operation.php | 6 +++++- Spanner/src/Transaction.php | 2 ++ Spanner/src/TransactionConfigurationTrait.php | 4 ++++ 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Spanner/src/Connection/Grpc.php b/Spanner/src/Connection/Grpc.php index 182f68fc23e7..a5395dca5201 100644 --- a/Spanner/src/Connection/Grpc.php +++ b/Spanner/src/Connection/Grpc.php @@ -1111,7 +1111,9 @@ public function beginTransaction(array $args) $pdml = new PartitionedDml(); $options->setPartitionedDml($pdml); $args = $this->addLarHeader($args, $this->larEnabled); - } elseif (isset($transactionOptions['isolationLevel'])) { + } + + if (isset($transactionOptions['isolationLevel'])) { $options->setIsolationLevel($transactionOptions['isolationLevel']); } diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index 257b1ccfafec..b339ed190626 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -811,6 +811,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 @@ -822,9 +824,8 @@ public function transaction(array $options = []) throw new \BadMethodCallException('Nested transactions are not supported by this client.'); } - // There isn't anything configurable here. $options['transactionOptions'] = $this->configureTransactionOptions([ - 'isolationLevel' => $this->isolationLevel + 'isolationLevel' => $options['isolationLevel'] ?? $this->isolationLevel ]); $session = $this->selectSession( @@ -949,6 +950,10 @@ public function runTransaction(callable $operation, array $options = []) if (!isset($options['transactionOptions']['partitionedDml'])) { $options['begin'] = $options['transactionOptions']; } + + if (!isset($options['transactionOptions']['isolationLevel'])) { + $options['transactionOptions']['isolationLevel'] = IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED; + } } else { $options['isRetry'] = true; } diff --git a/Spanner/src/Operation.php b/Spanner/src/Operation.php index 6c8aeecace91..e9f4d9748994 100644 --- a/Spanner/src/Operation.php +++ b/Spanner/src/Operation.php @@ -25,6 +25,7 @@ use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Session\Session; use Google\Cloud\Spanner\V1\SpannerClient as GapicSpannerClient; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use Google\Rpc\Code; use InvalidArgumentException; @@ -455,6 +456,8 @@ 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. + * **Defaults to** IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED * } * @return Transaction */ @@ -463,7 +466,8 @@ public function transaction(Session $session, array $options = []) $options += [ 'singleUse' => false, 'isRetry' => false, - 'requestOptions' => [] + 'requestOptions' => [], + 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED ]; $transactionTag = $this->pluck('tag', $options, false); if (isset($transactionTag)) { diff --git a/Spanner/src/Transaction.php b/Spanner/src/Transaction.php index 3d740cac45f3..b7a1577e5777 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. diff --git a/Spanner/src/TransactionConfigurationTrait.php b/Spanner/src/TransactionConfigurationTrait.php index 31dfa76809c7..f0b6219174bf 100644 --- a/Spanner/src/TransactionConfigurationTrait.php +++ b/Spanner/src/TransactionConfigurationTrait.php @@ -152,6 +152,10 @@ private function configureTransactionOptions(array $options = []) $transactionOptions['excludeTxnFromChangeStreams'] = $options['excludeTxnFromChangeStreams']; } + if (isset($options['isolationLevel'])) { + $transactionOptions['isolationLevel'] = $options['isolationLevel']; + } + return $transactionOptions; } From 1185ae4ba48054ad2affa7e57f0d90ebd0646bed Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Tue, 15 Apr 2025 23:26:15 +0000 Subject: [PATCH 11/24] Update the Instance class --- Spanner/src/Instance.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Spanner/src/Instance.php b/Spanner/src/Instance.php index 43accfdf10e9..97fa56920444 100644 --- a/Spanner/src/Instance.php +++ b/Spanner/src/Instance.php @@ -539,7 +539,7 @@ public function database($name, array $options = []) $this->returnInt64AsObject, isset($options['database']) ? $options['database'] : [], isset($options['databaseRole']) ? $options['databaseRole'] : '', - isset($options['isolationLevel']) ? $options['isolationLevel'] : $this->isolationLevel, + $options['isolationLevel'] ?? $this->isolationLevel, ); } From e8d54c738ceb849eeee40326fd01bed701a693cf Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Wed, 16 Apr 2025 21:40:43 +0000 Subject: [PATCH 12/24] Add configuration for Database --- Spanner/src/Database.php | 21 +++++++++++++++++---- Spanner/src/Instance.php | 3 ++- Spanner/src/Transaction.php | 12 ++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index b339ed190626..ee9f687c0286 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -1648,11 +1648,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` @@ -1697,12 +1698,16 @@ public function execute($sql, array $options = []) $this->pluck('sessionOptions', $options, false) ?: [] ); - list( + [ $options['transaction'], $options['transactionContext'] - ) = $this->transactionSelector($options); + ] = $this->transactionSelector($options); $options = $this->addLarHeader($options, true, $options['transactionContext']); + if ($options['transactionType'] === SessionPoolInterface::CONTEXT_READWRITE && $this->isolationLevel) { + $options['transaction']['begin']['isolationLevel'] ??= $this->isolationLevel; + } + $options['directedReadOptions'] = $this->configureDirectedReadOptions( $options, $this->directedReadOptions ?? [] @@ -1767,7 +1772,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 */ @@ -1911,6 +1916,8 @@ 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. */ @@ -1924,10 +1931,16 @@ public function executePartitionedUpdate($statement, array $options = []) 'partitionedDml' => [], ] ]; + if (isset($options['transactionOptions']['excludeTxnFromChangeStreams'])) { $beginTransactionOptions['transactionOptions']['excludeTxnFromChangeStreams'] = $options['transactionOptions']['excludeTxnFromChangeStreams']; } + + if (isset($options['transactionOptions']['isolationLevel']) || $this->isolationLevel) { + $options['transactionOptions']['isolationLevel'] ??= $this->isolationLevel; + } + $transaction = $this->operation->transaction($session, $beginTransactionOptions); $options = $this->addLarHeader($options); diff --git a/Spanner/src/Instance.php b/Spanner/src/Instance.php index 97fa56920444..80fdd9a53177 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 @@ -175,7 +176,7 @@ public function __construct( $this->setLroProperties($lroConnection, $lroCallables, $this->name); $this->directedReadOptions = $options['directedReadOptions'] ?? []; - $this->isolationLevel = $options['isolationLevel']; + $this->isolationLevel = $options['isolationLevel'] ?? IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED; } /** diff --git a/Spanner/src/Transaction.php b/Spanner/src/Transaction.php index b7a1577e5777..6af89ce71f48 100644 --- a/Spanner/src/Transaction.php +++ b/Spanner/src/Transaction.php @@ -248,6 +248,18 @@ public function executeUpdate($sql, array $options = []) . ' This option should be set at the transaction level.' ); } + + if ( + $this->type() === self::TYPE_SINGLE_USE && + isset($options['transaction']['begin']['isolationLevel']) || + isset($options['transaction']['single_use']['isolationLevel']) + ) { + throw new ValidationException( + 'The isolation level can only be applied to read/write transactions.'. + 'Single use transactions are not read/write', + ); + } + $options = $this->buildUpdateOptions($options); return $this->operation ->executeUpdate($this->session, $this, $sql, $options); From 06896015e6d3c49c6614b28559562dfe0f7d9d56 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Thu, 17 Apr 2025 20:27:41 +0000 Subject: [PATCH 13/24] Add tests for isolationLevel --- Spanner/src/Database.php | 9 +- Spanner/tests/System/TransactionTest.php | 6 + Spanner/tests/Unit/DatabaseTest.php | 140 +++++++++++++++++++++ Spanner/tests/Unit/TransactionTypeTest.php | 10 +- 4 files changed, 158 insertions(+), 7 deletions(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index ee9f687c0286..5339168ffa6f 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -1698,13 +1698,13 @@ public function execute($sql, array $options = []) $this->pluck('sessionOptions', $options, false) ?: [] ); - [ + list( $options['transaction'], $options['transactionContext'] - ] = $this->transactionSelector($options); + ) = $this->transactionSelector($options); $options = $this->addLarHeader($options, true, $options['transactionContext']); - if ($options['transactionType'] === SessionPoolInterface::CONTEXT_READWRITE && $this->isolationLevel) { + if (isset($options['transaction']['readWrite'])) { $options['transaction']['begin']['isolationLevel'] ??= $this->isolationLevel; } @@ -1938,7 +1938,8 @@ public function executePartitionedUpdate($statement, array $options = []) } if (isset($options['transactionOptions']['isolationLevel']) || $this->isolationLevel) { - $options['transactionOptions']['isolationLevel'] ??= $this->isolationLevel; + $beginTransactionOptions['transactionOptions']['isolationLevel'] = + $options['transactionOptions']['isolationLevel'] ?? $this->isolationLevel; } $transaction = $this->operation->transaction($session, $beginTransactionOptions); diff --git a/Spanner/tests/System/TransactionTest.php b/Spanner/tests/System/TransactionTest.php index 99b9931cb025..02f3558f2185 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; /** * @group spanner @@ -409,6 +410,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 a63c361fc6a8..78b37d67c0f6 100644 --- a/Spanner/tests/Unit/DatabaseTest.php +++ b/Spanner/tests/Unit/DatabaseTest.php @@ -57,6 +57,7 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Google\Cloud\Core\Exception\ServiceException; +use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; /** * @group spanner @@ -970,6 +971,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); @@ -1258,6 +1291,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); @@ -1332,6 +1393,40 @@ public function testExecutePartitionedUpdate() $this->assertEquals(1, $res); } + public function testExecutePartitionedUpdateWithIsolationLevel() + { + $sql = 'UPDATE foo SET bar = @bar'; + $this->connection->beginTransaction(Argument::allOf( + Argument::withEntry('transactionOptions', [ + 'partitionedDml' => [], + 'isolationLevel' => IsolationLevel::REPEATABLE_READ, + ]), + Argument::withEntry('singleUse', false) + ))->shouldBeCalled()->willReturn([ + 'id' => self::TRANSACTION + ]); + + $this->connection->executeStreamingSql(Argument::allOf( + Argument::withEntry('sql', $sql), + Argument::withEntry('transaction', [ + 'id' => self::TRANSACTION, + ]), + Argument::withEntry('transactionOptions', [ + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ]), + Argument::withEntry('headers', ['x-goog-spanner-route-to-leader' => ['true']]) + ))->shouldBeCalled()->willReturn($this->resultGenerator(true)); + + $this->refreshOperation($this->database, $this->connection->reveal()); + $res = $this->database->executePartitionedUpdate($sql, [ + 'transactionOptions' => [ + 'isolationLevel' => IsolationLevel::REPEATABLE_READ + ] + ]); + + $this->assertEquals(1, $res); + } + public function testRead() { $table = 'Table'; @@ -2139,6 +2234,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]] + ); + } + private function createStreamingAPIArgs() { $row = ['id' => 1]; 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( From 80625cd450f8fa5a075820e704490c2adfb3c822 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Thu, 17 Apr 2025 20:34:55 +0000 Subject: [PATCH 14/24] Fix style issues --- Spanner/src/Transaction.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Spanner/src/Transaction.php b/Spanner/src/Transaction.php index 6af89ce71f48..dd7d3d76d4a0 100644 --- a/Spanner/src/Transaction.php +++ b/Spanner/src/Transaction.php @@ -249,13 +249,12 @@ public function executeUpdate($sql, array $options = []) ); } - if ( - $this->type() === self::TYPE_SINGLE_USE && + if ($this->type() === self::TYPE_SINGLE_USE && isset($options['transaction']['begin']['isolationLevel']) || isset($options['transaction']['single_use']['isolationLevel']) ) { throw new ValidationException( - 'The isolation level can only be applied to read/write transactions.'. + 'The isolation level can only be applied to read/write transactions.' . 'Single use transactions are not read/write', ); } From fac546098cd985c41a6b03aaa3bff211a8b5abeb Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Thu, 17 Apr 2025 21:47:19 +0000 Subject: [PATCH 15/24] Update the executePartitionUpdate method --- Spanner/src/Database.php | 11 ++++++----- Spanner/tests/Unit/DatabaseTest.php | 26 +++++--------------------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index 5339168ffa6f..c895e7e9e9aa 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -40,6 +40,7 @@ use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use Google\Cloud\Spanner\V1\TypeCode; use Google\Rpc\Code; +use InvalidArgumentException; /** * Represents a Cloud Spanner Database. @@ -1924,6 +1925,11 @@ public function batchWrite(array $mutationGroups, array $options = []) public function executePartitionedUpdate($statement, array $options = []) { unset($options['requestOptions']['transactionTag']); + + if (isset($options['transactionOptions']['isolationLevel'])) { + throw new InvalidArgumentException('Partitioned DML cannot be configured with an isolation level'); + } + $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); $beginTransactionOptions = [ @@ -1937,11 +1943,6 @@ public function executePartitionedUpdate($statement, array $options = []) $options['transactionOptions']['excludeTxnFromChangeStreams']; } - if (isset($options['transactionOptions']['isolationLevel']) || $this->isolationLevel) { - $beginTransactionOptions['transactionOptions']['isolationLevel'] = - $options['transactionOptions']['isolationLevel'] ?? $this->isolationLevel; - } - $transaction = $this->operation->transaction($session, $beginTransactionOptions); $options = $this->addLarHeader($options); diff --git a/Spanner/tests/Unit/DatabaseTest.php b/Spanner/tests/Unit/DatabaseTest.php index 78b37d67c0f6..dabbb3a4758b 100644 --- a/Spanner/tests/Unit/DatabaseTest.php +++ b/Spanner/tests/Unit/DatabaseTest.php @@ -58,6 +58,8 @@ use Prophecy\PhpUnit\ProphecyTrait; use Google\Cloud\Core\Exception\ServiceException; use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; +use InvalidArgumentException; +use Kreait\Firebase\Exception\Messaging\InvalidArgument; /** * @group spanner @@ -1393,31 +1395,13 @@ public function testExecutePartitionedUpdate() $this->assertEquals(1, $res); } - public function testExecutePartitionedUpdateWithIsolationLevel() + public function testExecutePartitionedUpdateWithIsolationLevelShouldRaise() { $sql = 'UPDATE foo SET bar = @bar'; - $this->connection->beginTransaction(Argument::allOf( - Argument::withEntry('transactionOptions', [ - 'partitionedDml' => [], - 'isolationLevel' => IsolationLevel::REPEATABLE_READ, - ]), - Argument::withEntry('singleUse', false) - ))->shouldBeCalled()->willReturn([ - 'id' => self::TRANSACTION - ]); - - $this->connection->executeStreamingSql(Argument::allOf( - Argument::withEntry('sql', $sql), - Argument::withEntry('transaction', [ - 'id' => self::TRANSACTION, - ]), - Argument::withEntry('transactionOptions', [ - 'isolationLevel' => IsolationLevel::REPEATABLE_READ - ]), - Argument::withEntry('headers', ['x-goog-spanner-route-to-leader' => ['true']]) - ))->shouldBeCalled()->willReturn($this->resultGenerator(true)); $this->refreshOperation($this->database, $this->connection->reveal()); + $this->expectException(InvalidArgumentException::class); + $res = $this->database->executePartitionedUpdate($sql, [ 'transactionOptions' => [ 'isolationLevel' => IsolationLevel::REPEATABLE_READ From 9750d98723a2ad44858b8023fde3466c800d300f Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Mon, 2 Jun 2025 21:41:17 +0000 Subject: [PATCH 16/24] Perform a transaction with the Client's isolation level when set --- Spanner/src/Database.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index c895e7e9e9aa..a2cde3ffd347 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -935,7 +935,9 @@ public function runTransaction(callable $operation, array $options = []) ]; // There isn't anything configurable here. - $options['transactionOptions'] = $this->configureTransactionOptions($options['transactionOptions'] ?? []); + $options['transactionOptions'] = $this->configureTransactionOptions([ + 'isolationLevel' => $options['isolationLevel'] ?? $this->isolationLevel + ]); $session = $this->selectSession( SessionPoolInterface::CONTEXT_READWRITE, From 2a230cd9bf21e9dd3701d47cd073d2a0bce0b1da Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Wed, 4 Jun 2025 17:45:12 +0000 Subject: [PATCH 17/24] Fix test issues --- Spanner/src/Database.php | 11 ++++++++--- Spanner/src/Operation.php | 2 +- Spanner/tests/Unit/DatabaseTest.php | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index a2cde3ffd347..e4271944d460 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -37,6 +37,7 @@ 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; @@ -918,6 +919,8 @@ 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. @@ -934,10 +937,12 @@ public function runTransaction(callable $operation, array $options = []) 'maxRetries' => self::MAX_RETRIES, ]; + if (!isset($options['transactionOptions']['isolationLevel'])) { + $options['transactionOptions']['isolationLevel'] = $this->isolationLevel; + } + // There isn't anything configurable here. - $options['transactionOptions'] = $this->configureTransactionOptions([ - 'isolationLevel' => $options['isolationLevel'] ?? $this->isolationLevel - ]); + $options['transactionOptions'] = $this->configureTransactionOptions($options['transactionOptions'] ?? []); $session = $this->selectSession( SessionPoolInterface::CONTEXT_READWRITE, diff --git a/Spanner/src/Operation.php b/Spanner/src/Operation.php index e9f4d9748994..5cddb1749911 100644 --- a/Spanner/src/Operation.php +++ b/Spanner/src/Operation.php @@ -467,7 +467,7 @@ public function transaction(Session $session, array $options = []) 'singleUse' => false, 'isRetry' => false, 'requestOptions' => [], - 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED + // 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED ]; $transactionTag = $this->pluck('tag', $options, false); if (isset($transactionTag)) { diff --git a/Spanner/tests/Unit/DatabaseTest.php b/Spanner/tests/Unit/DatabaseTest.php index dabbb3a4758b..973ead97d3ec 100644 --- a/Spanner/tests/Unit/DatabaseTest.php +++ b/Spanner/tests/Unit/DatabaseTest.php @@ -82,7 +82,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; From ffaecf519269cc3a803dbbe7eecc6684645eda7e Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Wed, 4 Jun 2025 18:31:22 +0000 Subject: [PATCH 18/24] Change logic for assinging IsolationLevel on runTransaction --- Spanner/src/Database.php | 17 +++++++++-------- Spanner/src/Operation.php | 4 +--- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index 37bc7d7eb22a..f38dd8e80251 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -937,14 +937,15 @@ public function runTransaction(callable $operation, array $options = []) 'maxRetries' => self::MAX_RETRIES, ]; - if (!isset($options['transactionOptions']['isolationLevel'])) { + $transactionOptions = (isset($options['transactionOptions'])) ? $options['transactionOptions'] : []; + + if (!isset($transactionOptions['isolationLevel'])) { $options['transactionOptions']['isolationLevel'] = $this->isolationLevel; } - // There isn't anything configurable here. $options['transactionOptions'] = $this->configureTransactionOptions([ - 'isolationLevel' => $options['isolationLevel'] ?? $this->isolationLevel - ]); + 'isolationLevel' => $options['transactionOptions']['isolationLevel'] ?? $this->isolationLevel + ] + $transactionOptions); $session = $this->selectSession( SessionPoolInterface::CONTEXT_READWRITE, @@ -956,14 +957,14 @@ 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']; } - - if (!isset($options['transactionOptions']['isolationLevel'])) { - $options['transactionOptions']['isolationLevel'] = IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED; - } } else { $options['isRetry'] = true; } diff --git a/Spanner/src/Operation.php b/Spanner/src/Operation.php index 5cddb1749911..b47e439ac4ce 100644 --- a/Spanner/src/Operation.php +++ b/Spanner/src/Operation.php @@ -457,7 +457,6 @@ public function read( * @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. - * **Defaults to** IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED * } * @return Transaction */ @@ -466,8 +465,7 @@ public function transaction(Session $session, array $options = []) $options += [ 'singleUse' => false, 'isRetry' => false, - 'requestOptions' => [], - // 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED + 'requestOptions' => [] ]; $transactionTag = $this->pluck('tag', $options, false); if (isset($transactionTag)) { From c24dbd5ed23a003182b0d82d45aa8b077e75eee8 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Wed, 4 Jun 2025 19:02:16 +0000 Subject: [PATCH 19/24] Fix the SnippetDatabase test --- Spanner/tests/Snippet/DatabaseTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Spanner/tests/Snippet/DatabaseTest.php b/Spanner/tests/Snippet/DatabaseTest.php index 486efd3922e0..d6c4c15eda24 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,7 @@ 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([ From 1990b0380e6736997fc5beeff36d2b6f7103b4c0 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Wed, 4 Jun 2025 19:13:58 +0000 Subject: [PATCH 20/24] Fix styling issues --- Spanner/src/Database.php | 3 ++- Spanner/src/Operation.php | 1 - Spanner/tests/Snippet/DatabaseTest.php | 7 ++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index f38dd8e80251..5d2261322476 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -919,7 +919,8 @@ 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} + * @type array transactionOptions Options for the transaction. + * {@see \Google\Cloud\Spanner\V1\TransactionOptions} * for available options * } * @return mixed The return value of `$operation`. diff --git a/Spanner/src/Operation.php b/Spanner/src/Operation.php index b47e439ac4ce..043d504bb6b5 100644 --- a/Spanner/src/Operation.php +++ b/Spanner/src/Operation.php @@ -25,7 +25,6 @@ use Google\Cloud\Spanner\Connection\ConnectionInterface; use Google\Cloud\Spanner\Session\Session; use Google\Cloud\Spanner\V1\SpannerClient as GapicSpannerClient; -use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use Google\Rpc\Code; use InvalidArgumentException; diff --git a/Spanner/tests/Snippet/DatabaseTest.php b/Spanner/tests/Snippet/DatabaseTest.php index d6c4c15eda24..0f0ab0493e0b 100644 --- a/Spanner/tests/Snippet/DatabaseTest.php +++ b/Spanner/tests/Snippet/DatabaseTest.php @@ -463,7 +463,12 @@ public function testRunTransactionRollback() ->shouldBeCalled(); $this->connection->executeStreamingSql( - Argument::withEntry('transaction', ['begin' => ['readWrite' => [], 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED]]) + Argument::withEntry('transaction', [ + 'begin' => [ + 'readWrite' => [], + 'isolationLevel' => IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED + ] + ]) ) ->shouldBeCalled() ->willReturn($this->resultGeneratorData([ From 00e98f3c44f94863004261cfcbf23de987692463 Mon Sep 17 00:00:00 2001 From: Hector Mendoza Jacobo Date: Fri, 3 Oct 2025 19:07:44 -0400 Subject: [PATCH 21/24] Add the correct documentation for snapshotIsolation --- Spanner/src/Database.php | 3 ++- Spanner/src/Instance.php | 4 +++- Spanner/src/Transaction.php | 1 + Spanner/tests/Unit/DatabaseTest.php | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index f6639fc77f46..cd971ee10bb6 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -1932,13 +1932,14 @@ public function batchWrite(array $mutationGroups, array $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 InvalidArgumentException('Partitioned DML cannot be configured with an isolation level'); + throw new ValidationException('Partitioned DML cannot be configured with an isolation level'); } $session = $this->selectSession(SessionPoolInterface::CONTEXT_READWRITE); diff --git a/Spanner/src/Instance.php b/Spanner/src/Instance.php index 80fdd9a53177..6ef646012a19 100644 --- a/Spanner/src/Instance.php +++ b/Spanner/src/Instance.php @@ -154,7 +154,7 @@ 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. - * @param int $isolationLevel The level of Isolation for the transactions executed by this Client's instance. + * @type int $isolationLevel The level of Isolation for the transactions executed by this Client's instance. * **Defaults to** IsolationLevel::ISOLATION_LEVEL_UNSPECIFIED * } */ @@ -524,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 */ diff --git a/Spanner/src/Transaction.php b/Spanner/src/Transaction.php index dd7d3d76d4a0..9ad1dc750f49 100644 --- a/Spanner/src/Transaction.php +++ b/Spanner/src/Transaction.php @@ -239,6 +239,7 @@ public function getCommitStats() * been set when creating the transaction. * } * @return int The number of rows modified. + * @throws ValidationException */ public function executeUpdate($sql, array $options = []) { diff --git a/Spanner/tests/Unit/DatabaseTest.php b/Spanner/tests/Unit/DatabaseTest.php index 4d251ed07855..1157838fb0e2 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; @@ -59,7 +60,6 @@ use Google\Cloud\Core\Exception\ServiceException; use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use InvalidArgumentException; -use Kreait\Firebase\Exception\Messaging\InvalidArgument; use Google\Cloud\Spanner\V1\ReadRequest\LockHint; use Google\Cloud\Spanner\V1\ReadRequest\OrderBy; @@ -1402,7 +1402,7 @@ public function testExecutePartitionedUpdateWithIsolationLevelShouldRaise() $sql = 'UPDATE foo SET bar = @bar'; $this->refreshOperation($this->database, $this->connection->reveal()); - $this->expectException(InvalidArgumentException::class); + $this->expectException(ValidationException::class); $res = $this->database->executePartitionedUpdate($sql, [ 'transactionOptions' => [ From 53892bcc47bbdf7d2bd289430693dbd56039ae00 Mon Sep 17 00:00:00 2001 From: Hector Mendoza Jacobo Date: Fri, 3 Oct 2025 20:32:46 -0400 Subject: [PATCH 22/24] Remove unnecessary import --- Spanner/src/Database.php | 1 - 1 file changed, 1 deletion(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index b815bc76fa08..d712e99626fb 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -41,7 +41,6 @@ use Google\Cloud\Spanner\V1\TransactionOptions\IsolationLevel; use Google\Cloud\Spanner\V1\TypeCode; use Google\Rpc\Code; -use InvalidArgumentException; /** * Represents a Cloud Spanner Database. From 975ef470aa0dc813cd0324f1d48e623cbdc9624e Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Tue, 7 Oct 2025 01:40:10 +0000 Subject: [PATCH 23/24] Update logic for transaction to handle snapshotIsolation --- Spanner/src/Transaction.php | 39 +++++++----- Spanner/tests/Unit/OperationTest.php | 22 +++++++ Spanner/tests/Unit/SpannerClientTest.php | 76 +++++++++++++++++++++++- Spanner/tests/Unit/TransactionTest.php | 59 ++++++++++++++++++ 4 files changed, 181 insertions(+), 15 deletions(-) diff --git a/Spanner/src/Transaction.php b/Spanner/src/Transaction.php index 9ad1dc750f49..f72e554f3fb1 100644 --- a/Spanner/src/Transaction.php +++ b/Spanner/src/Transaction.php @@ -231,12 +231,15 @@ 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 @@ -250,14 +253,17 @@ public function executeUpdate($sql, array $options = []) ); } - if ($this->type() === self::TYPE_SINGLE_USE && - isset($options['transaction']['begin']['isolationLevel']) || - isset($options['transaction']['single_use']['isolationLevel']) - ) { - throw new ValidationException( - 'The isolation level can only be applied to read/write transactions.' . - 'Single use transactions are not read/write', - ); + 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); @@ -530,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/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 From a21c6a71cc81b3517dba034b603c6c69f13844a3 Mon Sep 17 00:00:00 2001 From: hectorhammett Date: Tue, 7 Oct 2025 01:42:04 +0000 Subject: [PATCH 24/24] Removed unnecessary line --- Spanner/src/Database.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/Spanner/src/Database.php b/Spanner/src/Database.php index d712e99626fb..7772f54ab19b 100644 --- a/Spanner/src/Database.php +++ b/Spanner/src/Database.php @@ -940,8 +940,6 @@ public function runTransaction(callable $operation, array $options = []) 'isolationLevel' => $options['transactionOptions']['isolationLevel'] ?? $this->isolationLevel ] + $transactionOptions); - $options['transactionOptions'] = $this->configureTransactionOptions($options['transactionOptions'] ?? []); - $session = $this->selectSession( SessionPoolInterface::CONTEXT_READWRITE, $this->pluck('sessionOptions', $options, false) ?: []