diff --git a/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/BigQuery.java b/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/BigQuery.java index ab16ed40f7c9..88fa3f7bf5e7 100644 --- a/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/BigQuery.java +++ b/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/BigQuery.java @@ -306,6 +306,23 @@ public static DatasetListOption all() { } } + /** Class for specifying project list options. */ + class ProjectListOption extends Option { + private static final long serialVersionUID = 1L; + + private ProjectListOption(BigQueryRpc.Option option, Object value) { + super(option, value); + } + + public static ProjectListOption pageSize(long pageSize) { + return new ProjectListOption(BigQueryRpc.Option.MAX_RESULTS, pageSize); + } + + public static ProjectListOption pageToken(String pageToken) { + return new ProjectListOption(BigQueryRpc.Option.PAGE_TOKEN, pageToken); + } + } + /** Class for specifying dataset get, create and update options. */ class DatasetOption extends Option { @@ -951,6 +968,15 @@ public int hashCode() { */ Page listDatasets(DatasetListOption... options); + /** + * Lists the projects accessible to the caller. + * + * @param options options for listing projects + * @return a page of projects + */ + @InternalApi + Page listProjects(ProjectListOption... options); + /** * Lists the datasets in the provided project. This method returns partial information on each * dataset: ({@link Dataset#getDatasetId()}, {@link Dataset#getFriendlyName()} and {@link diff --git a/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/BigQueryImpl.java b/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/BigQueryImpl.java index 74c9ce60e84f..93afe1484f4a 100644 --- a/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/BigQueryImpl.java +++ b/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/BigQueryImpl.java @@ -25,6 +25,7 @@ import com.google.api.gax.paging.Page; import com.google.api.services.bigquery.model.ErrorProto; import com.google.api.services.bigquery.model.GetQueryResultsResponse; +import com.google.api.services.bigquery.model.ProjectList; import com.google.api.services.bigquery.model.QueryRequest; import com.google.api.services.bigquery.model.TableDataInsertAllRequest; import com.google.api.services.bigquery.model.TableDataInsertAllRequest.Rows; @@ -65,6 +66,25 @@ final class BigQueryImpl extends BaseService implements BigQuery { + private static class ProjectPageFetcher implements NextPageFetcher { + + private static final long serialVersionUID = 1L; + private final Map requestOptions; + private final BigQueryOptions serviceOptions; + + ProjectPageFetcher( + BigQueryOptions serviceOptions, String cursor, Map optionMap) { + this.requestOptions = + PageImpl.nextRequestOptions(BigQueryRpc.Option.PAGE_TOKEN, cursor, optionMap); + this.serviceOptions = serviceOptions; + } + + @Override + public Page getNextPage() { + return listProjects(serviceOptions, requestOptions); + } + } + private static class DatasetPageFetcher implements NextPageFetcher { private static final long serialVersionUID = -3057564042439021278L; @@ -307,6 +327,49 @@ public com.google.api.services.bigquery.model.Dataset call() throws IOException } } + @Override + public Page listProjects(ProjectListOption... options) { + Span projectsList = null; + if (getOptions().isOpenTelemetryTracingEnabled() + && getOptions().getOpenTelemetryTracer() != null) { + projectsList = + getOptions() + .getOpenTelemetryTracer() + .spanBuilder("com.google.cloud.bigquery.BigQuery.listProjects") + .setAllAttributes(otelAttributesFromOptions(options)) + .startSpan(); + } + try (Scope projectsListScope = projectsList != null ? projectsList.makeCurrent() : null) { + return listProjects(getOptions(), optionMap(options)); + } finally { + if (projectsList != null) { + projectsList.end(); + } + } + } + + private static Page listProjects( + final BigQueryOptions serviceOptions, final Map optionsMap) { + Tuple> result = + serviceOptions.getBigQueryRpcV2().listProjects(optionsMap); + String nextPageToken = result.x(); + Iterable projects = + Iterables.transform( + result.y() != null ? result.y() : ImmutableList.of(), + projectPb -> + new Project( + projectPb.getId(), + projectPb.getNumericId() != null + ? String.valueOf(projectPb.getNumericId()) + : null, + projectPb.getProjectReference() != null + ? projectPb.getProjectReference().getProjectId() + : null, + projectPb.getFriendlyName())); + return new PageImpl<>( + new ProjectPageFetcher(serviceOptions, nextPageToken, optionsMap), nextPageToken, projects); + } + @Override public Table create(TableInfo tableInfo, TableOption... options) { final com.google.api.services.bigquery.model.Table tablePb = diff --git a/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/Project.java b/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/Project.java new file mode 100644 index 000000000000..f0161792abf6 --- /dev/null +++ b/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/Project.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.bigquery; + +import com.google.api.core.BetaApi; +import java.io.Serializable; +import java.util.Objects; + +@BetaApi +public class Project implements Serializable { + private static final long serialVersionUID = 1L; + + private final String id; + private final String numericId; + private final String projectId; + private final String friendlyName; + + public Project(String id, String numericId, String projectId, String friendlyName) { + this.id = id; + this.numericId = numericId; + this.projectId = projectId; + this.friendlyName = friendlyName; + } + + public String getId() { + return id; + } + + public String getNumericId() { + return numericId; + } + + public String getProjectId() { + return projectId; + } + + public String getFriendlyName() { + return friendlyName; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Project project = (Project) o; + return Objects.equals(id, project.id) + && Objects.equals(numericId, project.numericId) + && Objects.equals(projectId, project.projectId) + && Objects.equals(friendlyName, project.friendlyName); + } + + @Override + public int hashCode() { + return Objects.hash(id, numericId, projectId, friendlyName); + } + + @Override + public String toString() { + return "Project{" + + "id='" + + id + + '\'' + + ", numericId='" + + numericId + + '\'' + + ", projectId='" + + projectId + + '\'' + + ", friendlyName='" + + friendlyName + + '\'' + + '}'; + } +} diff --git a/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/spi/v2/BigQueryRpc.java b/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/spi/v2/BigQueryRpc.java index 65fd45d02a10..3898e6a10d98 100644 --- a/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/spi/v2/BigQueryRpc.java +++ b/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/spi/v2/BigQueryRpc.java @@ -16,6 +16,7 @@ package com.google.cloud.bigquery.spi.v2; +import com.google.api.core.InternalApi; import com.google.api.core.InternalExtensionOnly; import com.google.api.services.bigquery.Bigquery.Jobs.Query; import com.google.api.services.bigquery.model.Dataset; @@ -108,6 +109,15 @@ Boolean getBoolean(Map options) { */ Tuple> listDatasets(String projectId, Map options); + /** + * Lists the projects accessible to the caller, keyed by page token. + * + * @throws BigQueryException upon failure + */ + @InternalApi + Tuple> listProjects( + Map options); + /** * Creates a new dataset. * diff --git a/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/spi/v2/HttpBigQueryRpc.java b/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/spi/v2/HttpBigQueryRpc.java index b89cb99d4d64..41fe993b204e 100644 --- a/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/spi/v2/HttpBigQueryRpc.java +++ b/java-bigquery/google-cloud-bigquery/src/main/java/com/google/cloud/bigquery/spi/v2/HttpBigQueryRpc.java @@ -50,6 +50,7 @@ import com.google.api.services.bigquery.model.Model; import com.google.api.services.bigquery.model.ModelReference; import com.google.api.services.bigquery.model.Policy; +import com.google.api.services.bigquery.model.ProjectList; import com.google.api.services.bigquery.model.QueryRequest; import com.google.api.services.bigquery.model.QueryResponse; import com.google.api.services.bigquery.model.Routine; @@ -261,6 +262,49 @@ public Tuple> listDatasetsSkipExceptionTranslation( }); } + @Override + public Tuple> listProjects(Map options) { + try { + validateRPC(); + Bigquery.Projects.List request = bigquery.projects().list(); + Long maxResults = Option.MAX_RESULTS.getLong(options); + if (maxResults != null) { + request.setMaxResults(maxResults); + } + String pageToken = Option.PAGE_TOKEN.getString(options); + if (pageToken != null) { + request.setPageToken(pageToken); + } + request + .getRequestHeaders() + .set("x-goog-otel-enabled", this.options.isOpenTelemetryTracingEnabled()); + + String gcpResourceDestinationId = RESOURCE_PROJECT_PREFIX + this.options.getProjectId(); + + return executeWithSpan( + createRpcTracingSpan( + "com.google.cloud.bigquery.BigQueryRpc.listProjects", + "ProjectService", + "ListProjects", + gcpResourceDestinationId, + request.getUriTemplate(), + options), + span -> { + if (span != null) { + span.setAttribute("bq.rpc.page_token", request.getPageToken()); + } + ProjectList projectList = request.execute(); + Iterable projects = projectList.getProjects(); + if (span != null) { + span.setAttribute("bq.rpc.next_page_token", projectList.getNextPageToken()); + } + return Tuple.of(projectList.getNextPageToken(), projects); + }); + } catch (IOException e) { + throw translate(e); + } + } + @Override public Dataset create(Dataset dataset, Map options) { try { diff --git a/java-bigquery/google-cloud-bigquery/src/test/java/com/google/cloud/bigquery/BigQueryImplTest.java b/java-bigquery/google-cloud-bigquery/src/test/java/com/google/cloud/bigquery/BigQueryImplTest.java index 20a6ef679e89..5c8d5b3dc5f0 100644 --- a/java-bigquery/google-cloud-bigquery/src/test/java/com/google/cloud/bigquery/BigQueryImplTest.java +++ b/java-bigquery/google-cloud-bigquery/src/test/java/com/google/cloud/bigquery/BigQueryImplTest.java @@ -41,6 +41,8 @@ import com.google.api.services.bigquery.model.GetQueryResultsResponse; import com.google.api.services.bigquery.model.JobConfigurationQuery; import com.google.api.services.bigquery.model.JobStatistics; +import com.google.api.services.bigquery.model.ProjectList; +import com.google.api.services.bigquery.model.ProjectReference; import com.google.api.services.bigquery.model.QueryRequest; import com.google.api.services.bigquery.model.TableCell; import com.google.api.services.bigquery.model.TableDataInsertAllRequest; @@ -777,6 +779,50 @@ void testListDatasetsWithOptions() throws IOException { verify(bigqueryRpcMock).listDatasetsSkipExceptionTranslation(PROJECT, DATASET_LIST_OPTIONS); } + @Test + void testListProjects() { + bigquery = options.getService(); + ProjectList.Projects p1 = + new ProjectList.Projects() + .setId("id1") + .setNumericId(BigInteger.valueOf(111L)) + .setProjectReference(new ProjectReference().setProjectId("p-1")) + .setFriendlyName("fn1"); + ProjectList.Projects p2 = + new ProjectList.Projects() + .setId("id2") + .setNumericId(BigInteger.valueOf(222L)) + .setProjectReference(new ProjectReference().setProjectId("p-2")) + .setFriendlyName("fn2"); + ImmutableList projectsPb = ImmutableList.of(p1, p2); + Tuple> result = Tuple.of(CURSOR, projectsPb); + + when(bigqueryRpcMock.listProjects(EMPTY_RPC_OPTIONS)).thenReturn(result); + + Page page = bigquery.listProjects(); + assertEquals(CURSOR, page.getNextPageToken()); + + Project expected1 = new Project("id1", "111", "p-1", "fn1"); + Project expected2 = new Project("id2", "222", "p-2", "fn2"); + assertArrayEquals( + new Project[] {expected1, expected2}, Iterables.toArray(page.getValues(), Project.class)); + verify(bigqueryRpcMock).listProjects(EMPTY_RPC_OPTIONS); + } + + @Test + void testListEmptyProjects() { + bigquery = options.getService(); + ImmutableList projectsPb = ImmutableList.of(); + Tuple> result = Tuple.of(null, projectsPb); + + when(bigqueryRpcMock.listProjects(EMPTY_RPC_OPTIONS)).thenReturn(result); + + Page page = bigquery.listProjects(); + assertNull(page.getNextPageToken()); + assertArrayEquals(new Project[0], Iterables.toArray(page.getValues(), Project.class)); + verify(bigqueryRpcMock).listProjects(EMPTY_RPC_OPTIONS); + } + @Test void testDeleteDataset() throws IOException { when(bigqueryRpcMock.deleteDatasetSkipExceptionTranslation(PROJECT, DATASET, EMPTY_RPC_OPTIONS)) diff --git a/java-bigquery/google-cloud-bigquery/src/test/java/com/google/cloud/bigquery/spi/v2/HttpBigQueryRpcTest.java b/java-bigquery/google-cloud-bigquery/src/test/java/com/google/cloud/bigquery/spi/v2/HttpBigQueryRpcTest.java index c6961ea63d15..4c3bf67921e6 100644 --- a/java-bigquery/google-cloud-bigquery/src/test/java/com/google/cloud/bigquery/spi/v2/HttpBigQueryRpcTest.java +++ b/java-bigquery/google-cloud-bigquery/src/test/java/com/google/cloud/bigquery/spi/v2/HttpBigQueryRpcTest.java @@ -38,6 +38,7 @@ import com.google.api.services.bigquery.model.Model; import com.google.api.services.bigquery.model.ModelReference; import com.google.api.services.bigquery.model.Policy; +import com.google.api.services.bigquery.model.ProjectList; import com.google.api.services.bigquery.model.QueryRequest; import com.google.api.services.bigquery.model.Routine; import com.google.api.services.bigquery.model.RoutineReference; @@ -279,6 +280,30 @@ public void testListDatasetsTelemetry() throws Exception { Collections.singletonMap("bq.rpc.next_page_token", "next-page-token")); } + @Test + public void testListProjects() throws Exception { + setMockResponse( + "{\"kind\":\"bigquery#projectList\",\"projects\":[{\"id\":\"p1\",\"friendlyName\":\"Project 1\"}], \"nextPageToken\":\"token2\"}"); + + Map options = new java.util.HashMap<>(); + options.put(BigQueryRpc.Option.MAX_RESULTS, 10L); + options.put(BigQueryRpc.Option.PAGE_TOKEN, "token1"); + + com.google.cloud.Tuple> result = + rpc.listProjects(options); + + verifyRequest("GET", "/projects?maxResults=10&pageToken=token1"); + assertEquals("token2", result.x()); + assertNotNull(result.y()); + assertEquals("p1", result.y().iterator().next().getId()); + verifySpan( + "com.google.cloud.bigquery.BigQueryRpc.listProjects", + "ProjectService", + "ListProjects", + RESOURCE_PROJECT_PREFIX + PROJECT_ID, + Collections.singletonMap("bq.rpc.next_page_token", "token2")); + } + @Test public void testCreateDatasetTelemetry() throws Exception { setMockResponse(