Skip to content

fix(spanner): do not drop Spanner and GAX options on Database and Transaction methods#9381

Open
cy-yun wants to merge 8 commits into
mainfrom
fix/spanner-gax-options-propagation
Open

fix(spanner): do not drop Spanner and GAX options on Database and Transaction methods#9381
cy-yun wants to merge 8 commits into
mainfrom
fix/spanner-gax-options-propagation

Conversation

@cy-yun

@cy-yun cy-yun commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Fixes #9378, #9336

This PR addresses an issue where certain Google Cloud Spanner and underlying GAX options (such as requestOptions, headers, retrySettings, timeoutMillis, and transportOptions) were being inadvertently dropped by various Database and Transaction methods.

Previously, methods used pluckArray() with a highly restrictive and hardcoded list of allowed keys, silently ignoring and discarding valid user-provided configuration. This PR replaces that scattered logic with a centralized, unified OptionsValidator that preserves all allowed Spanner and GAX options, ensuring they correctly propagate down to the underlying Operation calls and API requests.

Changes Include:

  • Core\OptionsValidator: Introduced stripUnknownOptions() as a centralized utility to automatically strip unknown array keys while safely preserving all recognized GAPIC request properties, common Google Cloud PHP options, and GAX configurations.
  • Spanner\Database: Updated array extractions in insert, insertBatch, update, updateBatch, insertOrUpdate, insertOrUpdateBatch, replace, replaceBatch, delete, execute, and read to rely on the unified stripUnknownOptions() approach.
  • Spanner\TransactionalReadTrait: Updated execute() and read() to pass user configuration through the validator, preserving requestOptions and transportOptions.
  • Spanner\Operation: Updated partitionQuery() and partitionRead() to use the new option validator.
  • Spanner\Transaction: Updated commit() to propagate options via the unified validator while continuing to cleanly ignore requestTag (which is unsupported by the Spanner backend for commits).

Unrelated Unit Test Fix:

  • Spanner\ValueMapper: Resolved a PHP 8.1+ compatibility issue (Using null as an array offset is deprecated) by explicitly casting potentially null array keys to strings during type annotations.

Verification

  • Added Core\Tests\Unit\OptionsValidatorTest to comprehensively verify that recognized Spanner properties, Google Cloud options, and GAX options are preserved, while arbitrary invalid keys are correctly stripped.
  • Restructured Spanner\Tests\Unit\DatabaseTest, TransactionTest, and OperationTest to concisely verify option propagation across core methods. The tests now explicitly assert that a fabricated unknownOption is cleanly filtered out by the validator without crashing.

@product-auto-label product-auto-label Bot added the api: spanner Issues related to the Spanner API. label Jul 22, 2026
@cy-yun
cy-yun force-pushed the fix/spanner-gax-options-propagation branch 3 times, most recently from 727ef6b to 19a0ff1 Compare July 22, 2026 00:59
@cy-yun
cy-yun marked this pull request as ready for review July 22, 2026 01:16
@cy-yun
cy-yun requested a review from a team as a code owner July 22, 2026 01:16
@cy-yun
cy-yun enabled auto-merge (squash) July 22, 2026 02:12

@bshaffer bshaffer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! A few thoughts/suggestions

Comment thread Spanner/src/Database.php Outdated
'parameters',
'types',
'queryOptions',
'sessionOptions',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is sessionOptions coming from? I don't see it in ExecuteSqlRequest or in Operation::execute.

I did a brief search and I think it might be possible that this option is no longer supported

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added it because it was in the docs above. But you're right that it's not in the ExecuteSqlRequest. I'll remove it from everywhere.

Comment thread Spanner/src/Database.php Outdated
'retrySettings',
'timeoutMillis',
'transportOptions'
], $options);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we said pluckArray works, but we also discussed potentially adding OptionsValidator::stripUnknownOptions so we could do something like this:

        $options = $this->stripUnknownOptions(
            $options,
            ['parameters', 'types']
            CallOptions::class,
            ExecuteSqlRequest::class,
        );

How that I'm seeing the size of these lists, and the number of their occurrences, what are your thoughts on going that direction instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the stripUnknownOptions approach more as well. It's more elegant, extendable, and maintainable. I'll make that change.

Comment thread Spanner/src/Operation.php
new ExecuteBatchDmlRequest(),
CallOptions::class
CallOptions::class,
[],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this here for?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That empty array acts as the 4th parameter ($transactionOptions) in order to pass the ['route-to-leader'] configuration to the 5th parameter ($additionalOptions). It's solely for parameter positioning.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused because validateOptions uses variable parameters, and $transactionOptions is not a parameter.

private function validateOptions(array $options, array|Message|string ...$optionTypes): array

What seems to be happening is you're defining an empty set of parameters via [], and then saving them in an empty variable $_. This should instead look like this:

[$dmlRequest, $callOptions, $rtl] = $this->validateOptions(
    $options,
    new ExecuteBatchDmlRequest(),
    ['route-to-leader']
);

Comment thread Spanner/src/Transaction.php Outdated
}
if (isset($commitOptions['requestOptions']['requestTag'])) {
unset($commitOptions['requestOptions']['requestTag']);
if (empty($commitOptions['requestOptions'])) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this is a bit confusing as written, as empty also returns true for non-existant keys

Suggested change
if (empty($commitOptions['requestOptions'])) {
if (0 === count($commitOptions['requestOptions'])) {

@cy-yun
cy-yun force-pushed the fix/spanner-gax-options-propagation branch 10 times, most recently from 6f60e2a to 35386e0 Compare July 22, 2026 03:05
@cy-yun
cy-yun force-pushed the fix/spanner-gax-options-propagation branch from 35386e0 to c7e2ee6 Compare July 22, 2026 03:08
@cy-yun
cy-yun force-pushed the fix/spanner-gax-options-propagation branch from 18d66ba to 3ae10c6 Compare July 22, 2026 03:45

@bshaffer bshaffer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All small nits and suggestions! Looking really good.

Comment thread Spanner/src/Operation.php
new ExecuteBatchDmlRequest(),
CallOptions::class
CallOptions::class,
[],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused because validateOptions uses variable parameters, and $transactionOptions is not a parameter.

private function validateOptions(array $options, array|Message|string ...$optionTypes): array

What seems to be happening is you're defining an empty set of parameters via [], and then saving them in an empty variable $_. This should instead look like this:

[$dmlRequest, $callOptions, $rtl] = $this->validateOptions(
    $options,
    new ExecuteBatchDmlRequest(),
    ['route-to-leader']
);

public function executeUpdateBatch(array $statements, array $options = []): BatchDmlResult
{
$options = $this->buildUpdateOptions($options);
$options = $this->buildUpdateOptions($options, \Google\Cloud\Spanner\V1\ExecuteBatchDmlRequest::class);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: import this class

Comment on lines +480 to +483
$commitOptions = (new \Google\Cloud\Core\OptionsValidator())->stripUnknownOptions(
$options,
\Google\ApiCore\Options\CallOptions::class,
\Google\Cloud\Spanner\V1\CommitRequest::class

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: import these classes

$commitOptions = $this->pluckArray(
['returnCommitStats', 'maxCommitDelay'],
$options
$commitOptions = (new \Google\Cloud\Core\OptionsValidator())->stripUnknownOptions(

@bshaffer bshaffer Jul 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should either...

  1. add stripUnknownOptions to ApiHelperTrait and import it in this class, or
  2. add $this->optionsValidator in the constructor

...rather than create this object on the fly each time.

Because we are doing this in both Transaction and TransactionalReadTrait, if we add property $optionsValidator in the trait, we will be able to reference it here in Transaction as well.

Then we need to instantiate the $optionsValidator in Transaction::__construct(), and in SnapshotTrait::initialize() and we should be good to go

if (isset($options['requestOptions'])) {
$updateOptions['requestOptions'] = $options['requestOptions'];
}
$updateOptions = (new \Google\Cloud\Core\OptionsValidator())->stripUnknownOptions(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here: if we use ApiHelperTrait, then we can just call $this->stripUnknownOptions

} elseif ($optionType === CallOptions::class) {
$callOptionKeys = array_keys((new CallOptions([]))->toArray());
$allowedKeys = array_merge($allowedKeys, $callOptionKeys);
} elseif ($optionType instanceof Message

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should only support message class name in this case. There's no reason to provide a message instance (as there is for validateOptions).

Comment on lines +150 to +151
} elseif (is_string($optionType)) {
$allowedKeys[] = $optionType;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit but we probably don't need this... we can just say scalars should always be string[]

/**
* @var array
*/
private static array $allowedKeysCache = [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be at the top of the class between use ArrayTrait; and __construct

}

$options = $this->buildUpdateOptions($options);
$options = $this->buildUpdateOptions($options, \Google\Cloud\Spanner\V1\ExecuteSqlRequest::class);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: import this class

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api: spanner Issues related to the Spanner API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix dropped options in Spanner Database and Transaction operations

2 participants