Skip to content

Commit 3580407

Browse files
feat(bigquery): add internal listProjects API to core client (#13429)
b/521443900 This PR introduces support for fetching a list of GCP projects via the BigQuery API. This functionality is being exposed primarily to support cross-project dataset resolution in the native BigQuery JDBC driver. **Changes included:** * **Domain Model:** Added `Project` domain model representing a BigQuery project (`@BetaApi`). * **Client Interface:** Added `listProjects(ProjectListOption... options)` to the `BigQuery` client interface, marked as `@InternalApi` to preserve the public GA surface. * **SPI Layer:** Added `listProjects` mapping to `BigQueryRpc` and implemented the underlying HTTP execution in `HttpBigQueryRpc`, including pagination and OpenTelemetry tracing support. * **Implementation:** Implemented `ProjectPageFetcher` inside `BigQueryImpl` to seamlessly handle paginated project results. * **Testing:** Added unit test coverage in `BigQueryImplTest` and `HttpBigQueryRpcTest`.
1 parent 9113d80 commit 3580407

7 files changed

Lines changed: 346 additions & 0 deletions

File tree

java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/BigQuery.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,24 @@ public static DatasetListOption all() {
306306
}
307307
}
308308

309+
/** Class for specifying project list options. */
310+
@BetaApi
311+
class ProjectListOption extends Option {
312+
private static final long serialVersionUID = -7256063598324265038L;
313+
314+
private ProjectListOption(BigQueryRpc.Option option, Object value) {
315+
super(option, value);
316+
}
317+
318+
public static ProjectListOption pageSize(long pageSize) {
319+
return new ProjectListOption(BigQueryRpc.Option.MAX_RESULTS, pageSize);
320+
}
321+
322+
public static ProjectListOption pageToken(String pageToken) {
323+
return new ProjectListOption(BigQueryRpc.Option.PAGE_TOKEN, pageToken);
324+
}
325+
}
326+
309327
/** Class for specifying dataset get, create and update options. */
310328
class DatasetOption extends Option {
311329

@@ -951,6 +969,15 @@ public int hashCode() {
951969
*/
952970
Page<Dataset> listDatasets(DatasetListOption... options);
953971

972+
/**
973+
* Lists the projects accessible to the caller.
974+
*
975+
* @param options options for listing projects
976+
* @return a page of projects
977+
*/
978+
@BetaApi
979+
Page<Project> listProjects(ProjectListOption... options);
980+
954981
/**
955982
* Lists the datasets in the provided project. This method returns partial information on each
956983
* dataset: ({@link Dataset#getDatasetId()}, {@link Dataset#getFriendlyName()} and {@link

java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/BigQueryImpl.java

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import com.google.api.gax.paging.Page;
2626
import com.google.api.services.bigquery.model.ErrorProto;
2727
import com.google.api.services.bigquery.model.GetQueryResultsResponse;
28+
import com.google.api.services.bigquery.model.ProjectList;
2829
import com.google.api.services.bigquery.model.QueryRequest;
2930
import com.google.api.services.bigquery.model.TableDataInsertAllRequest;
3031
import com.google.api.services.bigquery.model.TableDataInsertAllRequest.Rows;
@@ -65,6 +66,25 @@
6566

6667
final class BigQueryImpl extends BaseService<BigQueryOptions> implements BigQuery {
6768

69+
private static class ProjectPageFetcher implements NextPageFetcher<Project> {
70+
71+
private static final long serialVersionUID = 1L;
72+
private final Map<BigQueryRpc.Option, ?> requestOptions;
73+
private final BigQueryOptions serviceOptions;
74+
75+
ProjectPageFetcher(
76+
BigQueryOptions serviceOptions, String cursor, Map<BigQueryRpc.Option, ?> optionMap) {
77+
this.requestOptions =
78+
PageImpl.nextRequestOptions(BigQueryRpc.Option.PAGE_TOKEN, cursor, optionMap);
79+
this.serviceOptions = serviceOptions;
80+
}
81+
82+
@Override
83+
public Page<Project> getNextPage() {
84+
return listProjects(serviceOptions, requestOptions);
85+
}
86+
}
87+
6888
private static class DatasetPageFetcher implements NextPageFetcher<Dataset> {
6989

7090
private static final long serialVersionUID = -3057564042439021278L;
@@ -307,6 +327,72 @@ public com.google.api.services.bigquery.model.Dataset call() throws IOException
307327
}
308328
}
309329

330+
@Override
331+
@BetaApi
332+
public Page<Project> listProjects(ProjectListOption... options) {
333+
Span projectsList = null;
334+
if (getOptions().isOpenTelemetryTracingEnabled()
335+
&& getOptions().getOpenTelemetryTracer() != null) {
336+
projectsList =
337+
getOptions()
338+
.getOpenTelemetryTracer()
339+
.spanBuilder("com.google.cloud.bigquery.BigQuery.listProjects")
340+
.setAllAttributes(otelAttributesFromOptions(options))
341+
.startSpan();
342+
}
343+
try (Scope projectsListScope = projectsList != null ? projectsList.makeCurrent() : null) {
344+
return listProjects(getOptions(), optionMap(options));
345+
} finally {
346+
if (projectsList != null) {
347+
projectsList.end();
348+
}
349+
}
350+
}
351+
352+
private static Page<Project> listProjects(
353+
final BigQueryOptions serviceOptions, final Map<BigQueryRpc.Option, ?> optionsMap) {
354+
try {
355+
Tuple<String, Iterable<ProjectList.Projects>> result =
356+
BigQueryRetryHelper.runWithRetries(
357+
new Callable<Tuple<String, Iterable<ProjectList.Projects>>>() {
358+
@Override
359+
public Tuple<String, Iterable<ProjectList.Projects>> call() {
360+
return serviceOptions.getBigQueryRpcV2().listProjects(optionsMap);
361+
}
362+
},
363+
serviceOptions.getRetrySettings(),
364+
serviceOptions.getResultRetryAlgorithm(),
365+
serviceOptions.getClock(),
366+
EMPTY_RETRY_CONFIG,
367+
serviceOptions.isOpenTelemetryTracingEnabled(),
368+
serviceOptions.getOpenTelemetryTracer());
369+
String nextPageToken = result.x();
370+
Iterable<Project> projects =
371+
Iterables.transform(
372+
result.y() != null ? result.y() : ImmutableList.<ProjectList.Projects>of(),
373+
new Function<ProjectList.Projects, Project>() {
374+
@Override
375+
public Project apply(ProjectList.Projects projectPb) {
376+
return new Project(
377+
projectPb.getId(),
378+
projectPb.getNumericId() != null
379+
? String.valueOf(projectPb.getNumericId())
380+
: null,
381+
projectPb.getProjectReference() != null
382+
? projectPb.getProjectReference().getProjectId()
383+
: null,
384+
projectPb.getFriendlyName());
385+
}
386+
});
387+
return new PageImpl<>(
388+
new ProjectPageFetcher(serviceOptions, nextPageToken, optionsMap),
389+
nextPageToken,
390+
projects);
391+
} catch (BigQueryRetryHelperException e) {
392+
throw BigQueryException.translateAndThrow(e);
393+
}
394+
}
395+
310396
@Override
311397
public Table create(TableInfo tableInfo, TableOption... options) {
312398
final com.google.api.services.bigquery.model.Table tablePb =
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.bigquery;
18+
19+
import com.google.api.core.BetaApi;
20+
import java.io.Serializable;
21+
import java.util.Objects;
22+
import javax.annotation.Nullable;
23+
24+
/**
25+
* Google BigQuery Project information. A project is the top-level container for Google Cloud
26+
* resources, and holds BigQuery dataset collections. This class wraps a BigQuery project resource,
27+
* providing details such as the project's unique alphanumeric ID, numeric project number, and
28+
* friendly display name.
29+
*
30+
* <p>Objects of this class can be obtained by listing projects accessible to the caller using
31+
* {@link BigQuery#listProjects(BigQuery.ProjectListOption...)}.
32+
*
33+
* @see <a href="https://cloud.google.com/bigquery/docs/reference/rest/v2/projects/list">Projects:
34+
* list</a>
35+
*/
36+
@BetaApi
37+
public class Project implements Serializable {
38+
private static final long serialVersionUID = -8123877292090683890L;
39+
40+
private final String id;
41+
private final String numericId;
42+
private final String projectId;
43+
private final String friendlyName;
44+
45+
public Project(String id, String numericId, String projectId, String friendlyName) {
46+
this.id = id;
47+
this.numericId = numericId;
48+
this.projectId = projectId;
49+
this.friendlyName = friendlyName;
50+
}
51+
52+
/** Returns the resource ID of the project. */
53+
public String getId() {
54+
return id;
55+
}
56+
57+
/** Returns the unique numeric project number. */
58+
@Nullable
59+
public String getNumericId() {
60+
return numericId;
61+
}
62+
63+
/** Returns the unique alphanumeric project ID. */
64+
@Nullable
65+
public String getProjectId() {
66+
return projectId;
67+
}
68+
69+
/** Returns the user-defined display name of the project. */
70+
@Nullable
71+
public String getFriendlyName() {
72+
return friendlyName;
73+
}
74+
75+
@Override
76+
public boolean equals(Object o) {
77+
if (this == o) return true;
78+
if (o == null || getClass() != o.getClass()) return false;
79+
Project project = (Project) o;
80+
return Objects.equals(id, project.id)
81+
&& Objects.equals(numericId, project.numericId)
82+
&& Objects.equals(projectId, project.projectId)
83+
&& Objects.equals(friendlyName, project.friendlyName);
84+
}
85+
86+
@Override
87+
public int hashCode() {
88+
return Objects.hash(id, numericId, projectId, friendlyName);
89+
}
90+
91+
@Override
92+
public String toString() {
93+
return "Project{"
94+
+ "id='"
95+
+ id
96+
+ '\''
97+
+ ", numericId='"
98+
+ numericId
99+
+ '\''
100+
+ ", projectId='"
101+
+ projectId
102+
+ '\''
103+
+ ", friendlyName='"
104+
+ friendlyName
105+
+ '\''
106+
+ '}';
107+
}
108+
}

java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/spi/v2/BigQueryRpc.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.cloud.bigquery.spi.v2;
1818

19+
import com.google.api.core.InternalApi;
1920
import com.google.api.core.InternalExtensionOnly;
2021
import com.google.api.services.bigquery.Bigquery.Jobs.Query;
2122
import com.google.api.services.bigquery.model.Dataset;
@@ -108,6 +109,15 @@ Boolean getBoolean(Map<Option, ?> options) {
108109
*/
109110
Tuple<String, Iterable<Dataset>> listDatasets(String projectId, Map<Option, ?> options);
110111

112+
/**
113+
* Lists the projects accessible to the caller, keyed by page token.
114+
*
115+
* @throws BigQueryException upon failure
116+
*/
117+
@InternalApi
118+
Tuple<String, Iterable<com.google.api.services.bigquery.model.ProjectList.Projects>> listProjects(
119+
Map<Option, ?> options);
120+
111121
/**
112122
* Creates a new dataset.
113123
*

java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/spi/v2/HttpBigQueryRpc.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import com.google.api.services.bigquery.model.Model;
5151
import com.google.api.services.bigquery.model.ModelReference;
5252
import com.google.api.services.bigquery.model.Policy;
53+
import com.google.api.services.bigquery.model.ProjectList;
5354
import com.google.api.services.bigquery.model.QueryRequest;
5455
import com.google.api.services.bigquery.model.QueryResponse;
5556
import com.google.api.services.bigquery.model.Routine;
@@ -261,6 +262,49 @@ public Tuple<String, Iterable<Dataset>> listDatasetsSkipExceptionTranslation(
261262
});
262263
}
263264

265+
@Override
266+
public Tuple<String, Iterable<ProjectList.Projects>> listProjects(Map<Option, ?> options) {
267+
try {
268+
validateRPC();
269+
Bigquery.Projects.List request = bigquery.projects().list();
270+
Long maxResults = Option.MAX_RESULTS.getLong(options);
271+
if (maxResults != null) {
272+
request.setMaxResults(maxResults);
273+
}
274+
String pageToken = Option.PAGE_TOKEN.getString(options);
275+
if (pageToken != null) {
276+
request.setPageToken(pageToken);
277+
}
278+
request
279+
.getRequestHeaders()
280+
.set("x-goog-otel-enabled", this.options.isOpenTelemetryTracingEnabled());
281+
282+
String gcpResourceDestinationId = RESOURCE_PROJECT_PREFIX + this.options.getProjectId();
283+
284+
return executeWithSpan(
285+
createRpcTracingSpan(
286+
"com.google.cloud.bigquery.BigQueryRpc.listProjects",
287+
"ProjectService",
288+
"ListProjects",
289+
gcpResourceDestinationId,
290+
request.getUriTemplate(),
291+
options),
292+
span -> {
293+
if (span != null) {
294+
span.setAttribute("bq.rpc.page_token", request.getPageToken());
295+
}
296+
ProjectList projectList = request.execute();
297+
Iterable<ProjectList.Projects> projects = projectList.getProjects();
298+
if (span != null) {
299+
span.setAttribute("bq.rpc.next_page_token", projectList.getNextPageToken());
300+
}
301+
return Tuple.of(projectList.getNextPageToken(), projects);
302+
});
303+
} catch (IOException e) {
304+
throw translate(e);
305+
}
306+
}
307+
264308
@Override
265309
public Dataset create(Dataset dataset, Map<Option, ?> options) {
266310
try {

java-bigquery/google-cloud-bigquery/src/test/java/com/google/cloud/bigquery/BigQueryImplTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
import com.google.api.services.bigquery.model.GetQueryResultsResponse;
4242
import com.google.api.services.bigquery.model.JobConfigurationQuery;
4343
import com.google.api.services.bigquery.model.JobStatistics;
44+
import com.google.api.services.bigquery.model.ProjectList;
45+
import com.google.api.services.bigquery.model.ProjectReference;
4446
import com.google.api.services.bigquery.model.QueryRequest;
4547
import com.google.api.services.bigquery.model.TableCell;
4648
import com.google.api.services.bigquery.model.TableDataInsertAllRequest;
@@ -777,6 +779,50 @@ void testListDatasetsWithOptions() throws IOException {
777779
verify(bigqueryRpcMock).listDatasetsSkipExceptionTranslation(PROJECT, DATASET_LIST_OPTIONS);
778780
}
779781

782+
@Test
783+
void testListProjects() {
784+
bigquery = options.getService();
785+
ProjectList.Projects p1 =
786+
new ProjectList.Projects()
787+
.setId("id1")
788+
.setNumericId(BigInteger.valueOf(111L))
789+
.setProjectReference(new ProjectReference().setProjectId("p-1"))
790+
.setFriendlyName("fn1");
791+
ProjectList.Projects p2 =
792+
new ProjectList.Projects()
793+
.setId("id2")
794+
.setNumericId(BigInteger.valueOf(222L))
795+
.setProjectReference(new ProjectReference().setProjectId("p-2"))
796+
.setFriendlyName("fn2");
797+
ImmutableList<ProjectList.Projects> projectsPb = ImmutableList.of(p1, p2);
798+
Tuple<String, Iterable<ProjectList.Projects>> result = Tuple.of(CURSOR, projectsPb);
799+
800+
when(bigqueryRpcMock.listProjects(EMPTY_RPC_OPTIONS)).thenReturn(result);
801+
802+
Page<Project> page = bigquery.listProjects();
803+
assertEquals(CURSOR, page.getNextPageToken());
804+
805+
Project expected1 = new Project("id1", "111", "p-1", "fn1");
806+
Project expected2 = new Project("id2", "222", "p-2", "fn2");
807+
assertArrayEquals(
808+
new Project[] {expected1, expected2}, Iterables.toArray(page.getValues(), Project.class));
809+
verify(bigqueryRpcMock).listProjects(EMPTY_RPC_OPTIONS);
810+
}
811+
812+
@Test
813+
void testListEmptyProjects() {
814+
bigquery = options.getService();
815+
ImmutableList<ProjectList.Projects> projectsPb = ImmutableList.of();
816+
Tuple<String, Iterable<ProjectList.Projects>> result = Tuple.of(null, projectsPb);
817+
818+
when(bigqueryRpcMock.listProjects(EMPTY_RPC_OPTIONS)).thenReturn(result);
819+
820+
Page<Project> page = bigquery.listProjects();
821+
assertNull(page.getNextPageToken());
822+
assertArrayEquals(new Project[0], Iterables.toArray(page.getValues(), Project.class));
823+
verify(bigqueryRpcMock).listProjects(EMPTY_RPC_OPTIONS);
824+
}
825+
780826
@Test
781827
void testDeleteDataset() throws IOException {
782828
when(bigqueryRpcMock.deleteDatasetSkipExceptionTranslation(PROJECT, DATASET, EMPTY_RPC_OPTIONS))

0 commit comments

Comments
 (0)