Skip to content

Commit a381cab

Browse files
authored
feat(spanner): add tpc support (#2333)
1 parent c54657f commit a381cab

File tree

3 files changed

+294
-4
lines changed

3 files changed

+294
-4
lines changed

src/index.ts

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,17 @@ export interface SpannerOptions extends GrpcClientOptions {
156156
defaultTransactionOptions?: Pick<RunTransactionOptions, 'isolationLevel'>;
157157
observabilityOptions?: ObservabilityOptions;
158158
interceptors?: any[];
159+
/**
160+
* The Trusted Cloud Domain (TPC) DNS of the service used to make requests.
161+
* Defaults to `googleapis.com`.
162+
* We support both camelCase and snake_case for the universe domain.
163+
* Customer may set any of these as both the options are same,
164+
* they both points to universe endpoint.
165+
* There is no preference for any of these option; however exception will be
166+
* thrown if both are set to different values.
167+
*/
168+
universe_domain?: string;
169+
universeDomain?: string;
159170
}
160171
export interface RequestConfig {
161172
client: string;
@@ -206,6 +217,45 @@ export type TranslateEnumKeys<
206217
[P in keyof T]: P extends U ? EnumKey<E> | null | undefined : T[P];
207218
};
208219

220+
/**
221+
* Retrieves the universe domain.
222+
*
223+
* This function checks for a universe domain in the following order:
224+
* 1. The `universeDomain` property within the provided spanner options.
225+
* 2. The `universe_domain` property within the provided spanner options.
226+
* 3. The `GOOGLE_CLOUD_UNIVERSE_DOMAIN` environment variable.
227+
* 4. If none of the above properties will be set, it will fallback to `googleapis.com`.
228+
*
229+
* For consistency with the Auth client, if the `universe_domain` option or the
230+
* `GOOGLE_CLOUD_UNIVERSE_DOMAIN` env variable is used, this function will also set the
231+
* `universeDomain` property within the provided `SpannerOptions` object. This ensures the
232+
* Spanner client's universe domain aligns with the universe configured for authentication.
233+
*
234+
* @param {SpannerOptions} options - The Spanner client options.
235+
* @returns {string} The universe domain.
236+
*/
237+
function getUniverseDomain(options: SpannerOptions): string {
238+
const universeDomainEnvVar =
239+
typeof process === 'object' && typeof process.env === 'object'
240+
? process.env['GOOGLE_CLOUD_UNIVERSE_DOMAIN']
241+
: undefined;
242+
const universeDomain =
243+
options?.universeDomain ??
244+
options?.universe_domain ??
245+
universeDomainEnvVar ??
246+
'googleapis.com';
247+
// if the options.universe_domain/GOOGLE_CLOUD_UNIVERSE_DOMAIN env variable is set,
248+
// set its value to the Spanner `universeDomain` options
249+
// to match it with the universe from Auth Client
250+
if (
251+
!options?.universeDomain &&
252+
(options?.universe_domain || process.env.GOOGLE_CLOUD_UNIVERSE_DOMAIN)
253+
) {
254+
options.universeDomain = universeDomain;
255+
}
256+
return universeDomain;
257+
}
258+
209259
/**
210260
* [Cloud Spanner](https://cloud.google.com/spanner) is a highly scalable,
211261
* transactional, managed, NewSQL database service. Cloud Spanner solves the
@@ -259,6 +309,7 @@ class Spanner extends GrpcService {
259309
directedReadOptions: google.spanner.v1.IDirectedReadOptions | null;
260310
defaultTransactionOptions: RunTransactionOptions;
261311
_observabilityOptions: ObservabilityOptions | undefined;
312+
private _universeDomain: string;
262313
readonly _nthClientId: number;
263314

264315
/**
@@ -351,6 +402,16 @@ class Spanner extends GrpcService {
351402
};
352403
delete options.defaultTransactionOptions;
353404

405+
if (
406+
options?.universe_domain &&
407+
options?.universeDomain &&
408+
options?.universe_domain !== options?.universeDomain
409+
) {
410+
throw new Error(
411+
'Please set either universe_domain or universeDomain, but not both.',
412+
);
413+
}
414+
354415
const emulatorHost = Spanner.getSpannerEmulatorHost();
355416
if (
356417
emulatorHost &&
@@ -361,12 +422,12 @@ class Spanner extends GrpcService {
361422
options.port = emulatorHost.port;
362423
options.sslCreds = grpc.credentials.createInsecure();
363424
}
425+
426+
const universeEndpoint = getUniverseDomain(options);
427+
const spannerUniverseEndpoint = 'spanner.' + universeEndpoint;
364428
const config = {
365429
baseUrl:
366-
options.apiEndpoint ||
367-
options.servicePath ||
368-
// TODO: for TPC, this needs to support universeDomain
369-
'spanner.googleapis.com',
430+
options.apiEndpoint || options.servicePath || spannerUniverseEndpoint,
370431
protosDir: path.resolve(__dirname, '../protos'),
371432
protoServices: {
372433
Operations: {
@@ -399,6 +460,11 @@ class Spanner extends GrpcService {
399460
);
400461
ensureInitialContextManagerSet();
401462
this._nthClientId = nextSpannerClientId();
463+
this._universeDomain = universeEndpoint;
464+
}
465+
466+
get universeDomain() {
467+
return this._universeDomain;
402468
}
403469

404470
/**

system-test/tpc-test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import {describe, it} from 'mocha';
16+
import {MutationSet, Spanner} from '../src';
17+
import * as assert from 'assert';
18+
19+
// INSTRUCTIONS FOR RUNNING TEST:
20+
// 1. Change describe.skip to describe.only below.
21+
// 2. Reassign process.env.GOOGLE_APPLICATION_CREDENTIALS to local key file.
22+
// 3. Reassign UNIVERSE_DOMAIN_CONSTANT to the universe domain to test.
23+
// 4. Run `npm run system-test`.
24+
25+
describe.skip('Universe domain tests', () => {
26+
// These tests are only designed to pass when using the service account
27+
// credentials for the universe domain environment so we skip them in the CI pipeline.
28+
29+
before(() => {
30+
process.env.GOOGLE_APPLICATION_CREDENTIALS = 'path to your credential file';
31+
});
32+
33+
async function runTest(spanner: Spanner, instanceId, databaseId) {
34+
const instance = spanner.instance(instanceId);
35+
const database = instance.database(databaseId);
36+
const tableName = 'VenueDetails';
37+
const table = database.table(tableName);
38+
39+
const schema = `CREATE TABLE ${tableName} (
40+
VenueId INT64 NOT NULL,
41+
VenueName STRING(100),
42+
Capacity INT64,
43+
) PRIMARY KEY (VenueId)`;
44+
45+
console.log(`Creating table ${table.name}`);
46+
const [, operation] = await table.create(schema);
47+
48+
await operation.promise();
49+
50+
console.log(`${table.name} create successfully.`);
51+
52+
const venuesTable = database.table(tableName);
53+
console.log(`Inserting data into the table ${table.name}`);
54+
await venuesTable.insert([
55+
{VenueId: 1, VenueName: 'Marc', Capacity: 100},
56+
{VenueId: 2, VenueName: 'Marc', Capacity: 200},
57+
]);
58+
59+
const mutations = new MutationSet();
60+
61+
mutations.insert(tableName, {
62+
VenueId: '3',
63+
VenueName: 'Marc',
64+
Capacity: 700,
65+
});
66+
mutations.insert(tableName, {
67+
VenueId: '4',
68+
VenueName: 'Marc',
69+
Capacity: 800,
70+
});
71+
mutations.update(tableName, {
72+
VenueId: '3',
73+
VenueName: 'Marc',
74+
Capacity: 300,
75+
});
76+
mutations.update(tableName, {
77+
VenueId: '4',
78+
VenueName: 'Marc',
79+
Capacity: 400,
80+
});
81+
82+
await database.writeAtLeastOnce(mutations);
83+
84+
const query = {
85+
columns: ['VenueId', 'VenueName', 'Capacity'],
86+
keySet: {
87+
all: true,
88+
},
89+
};
90+
91+
const [rows] = await venuesTable.read(query);
92+
93+
console.log(`Inserted ${rows.length} rows into the table ${table.name}`);
94+
95+
console.log(`Reading rows in the table ${table.name}`);
96+
97+
rows.forEach(row => {
98+
const json = row.toJSON();
99+
console.log(
100+
`VenueId: ${json.VenueId}, VenueName: ${json.VenueName}, Capacity: ${json.Capacity}`,
101+
);
102+
});
103+
104+
console.log(`deleting table ${table.name}`);
105+
await table.delete();
106+
console.log(`deleted table ${table.name}`);
107+
}
108+
it('should be able to run apis successfully against TPC environment', async () => {
109+
const UNIVERSE_DOMAIN_CONSTANT = 'my-universe-domain';
110+
const projectId = 'tpc-project-id';
111+
const universeDomain = UNIVERSE_DOMAIN_CONSTANT;
112+
const options = {
113+
projectId,
114+
universeDomain,
115+
};
116+
const spanner = new Spanner(options);
117+
const instanceId = 'your-test-instance-id';
118+
const databaseId = 'your-test-database-id';
119+
120+
try {
121+
await runTest(spanner, instanceId, databaseId);
122+
} catch (e) {
123+
assert.ifError(e);
124+
}
125+
});
126+
});

test/index.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,104 @@ describe('Spanner', () => {
434434
});
435435
});
436436

437+
describe('TPC tests', () => {
438+
const UNIVERSE_DOMAIN_CONSTANT = 'fake-universe-domain';
439+
440+
it('should have default universe domain set to `googleapis.com`', () => {
441+
try {
442+
const spanner = new Spanner();
443+
// get default universe domain from spanner object when
444+
// neither of univserDomain and universe_domain are set
445+
// nor env GOOGLE_CLOUD_UNIVERSE_DOMAIN is set
446+
assert.strictEqual(spanner.universeDomain, 'googleapis.com');
447+
// GoogleAuthOption's univserseDomain property must be undefined here
448+
// as it will get configure to default value in the gax library
449+
// please see: https://github.com/googleapis/gax-nodejs/blob/de43edd3524b7f995bd3cf5c34ddead03828b546/gax/src/grpc.ts#L431
450+
assert.strictEqual(spanner.options.universeDomain, undefined);
451+
} catch (err) {
452+
assert.ifError(err);
453+
}
454+
});
455+
456+
it('should optionally accept universeDomain', () => {
457+
const fakeOption = {
458+
universeDomain: UNIVERSE_DOMAIN_CONSTANT,
459+
};
460+
461+
try {
462+
const spanner = new Spanner(fakeOption);
463+
// get universe domain from spanner object
464+
assert.strictEqual(spanner.universeDomain, fakeOption.universeDomain);
465+
// GoogleAuthOption's univserseDomain property must be set
466+
// to match it with the universe from Auth Client
467+
assert.strictEqual(
468+
spanner.options.universeDomain,
469+
fakeOption.universeDomain,
470+
);
471+
} catch (err) {
472+
assert.ifError(err);
473+
}
474+
});
475+
476+
it('should optionally accept universe_domain', () => {
477+
const fakeOption = {
478+
universe_domain: UNIVERSE_DOMAIN_CONSTANT,
479+
};
480+
481+
try {
482+
const spanner = new Spanner(fakeOption);
483+
// get universe domain from spanner object
484+
assert.strictEqual(spanner.universeDomain, fakeOption.universe_domain);
485+
// GoogleAuthOption's univserseDomain property must be set
486+
// to match it with the universe from Auth Client
487+
assert.strictEqual(
488+
spanner.options.universeDomain,
489+
fakeOption.universe_domain,
490+
);
491+
} catch (err) {
492+
assert.ifError(err);
493+
}
494+
});
495+
496+
it('should set universe domain upon setting env GOOGLE_CLOUD_UNIVERSE_DOMAIN', () => {
497+
process.env.GOOGLE_CLOUD_UNIVERSE_DOMAIN = UNIVERSE_DOMAIN_CONSTANT;
498+
499+
try {
500+
const spanner = new Spanner();
501+
// get universe domain from spanner object
502+
assert.strictEqual(spanner.universeDomain, UNIVERSE_DOMAIN_CONSTANT);
503+
// GoogleAuthOption's univserseDomain property must be set
504+
// to match it with the universe from Auth Client
505+
assert.strictEqual(
506+
spanner.options.universeDomain,
507+
UNIVERSE_DOMAIN_CONSTANT,
508+
);
509+
} catch (err) {
510+
assert.ifError(err);
511+
}
512+
delete process.env.GOOGLE_CLOUD_UNIVERSE_DOMAIN;
513+
});
514+
515+
it('should throw an error if universe_domain and universeDomain both are set to different values', () => {
516+
const fakeOption = {
517+
universeDomain: 'fake-universe-domain-1',
518+
universe_domain: 'fake-universe-domain-2',
519+
};
520+
const fakeError = new Error(
521+
'Please set either universe_domain or universeDomain, but not both.',
522+
);
523+
524+
try {
525+
const spanner = new Spanner(fakeOption);
526+
// this line should never reach client must throw an error.
527+
throw new Error('should never reach this line');
528+
} catch (err) {
529+
assert.deepStrictEqual(err, fakeError);
530+
}
531+
delete process.env.GOOGLE_CLOUD_UNIVERSE_DOMAIN;
532+
});
533+
});
534+
437535
describe('date', () => {
438536
it('should create a default SpannerDate instance', () => {
439537
const customValue = {};

0 commit comments

Comments
 (0)