diff --git a/google-cloud-bigquery/Gemfile b/google-cloud-bigquery/Gemfile index 9b42a5f8075b..2be19c8666ac 100644 --- a/google-cloud-bigquery/Gemfile +++ b/google-cloud-bigquery/Gemfile @@ -21,6 +21,7 @@ end gem "autotest-suffix", "~> 1.1" gem "csv" +gem "ostruct" gem "google-cloud-bigquery-connection-v1", ">= 1.3.0", "< 1.5" gem "google-cloud-data_catalog", path: "../google-cloud-data_catalog" gem "google-cloud-storage", path: "../google-cloud-storage" diff --git a/google-cloud-bigquery/lib/google/cloud/bigquery/project.rb b/google-cloud-bigquery/lib/google/cloud/bigquery/project.rb index b5d2f3637140..fe8db86edf7c 100644 --- a/google-cloud-bigquery/lib/google/cloud/bigquery/project.rb +++ b/google-cloud-bigquery/lib/google/cloud/bigquery/project.rb @@ -962,18 +962,30 @@ def query query, format_options_use_int64_timestamp: true, reservation: nil, &block - job = query_job query, - params: params, - types: types, - external: external, - cache: cache, - dataset: dataset, - project: project, - standard_sql: standard_sql, - legacy_sql: legacy_sql, - session_id: session_id, - reservation: reservation, - &block + ensure_service! + options = { + params: params, types: types, external: external, cache: cache, + dataset: dataset, project: project, standard_sql: standard_sql, + legacy_sql: legacy_sql, session_id: session_id, reservation: reservation + } + + updater = QueryJob::Updater.from_options service, query, options + yield updater if block_given? + + if updater.stateless_query? + resp = service.query updater.to_query_request_gapi + if resp.job_complete + table_gapi = Google::Apis::BigqueryV2::Table.new schema: resp.schema + return Data.from_gapi_json JSON.parse(resp.to_json, symbolize_names: true), + table_gapi, nil, service, + format_options_use_int64_timestamp + end + job_ref = resp.job_reference + job = Job.from_gapi service.get_job(job_ref.job_id, location: job_ref.location), service + else + job = query_job query, **options, &block + end + job.wait_until_done! if job.failed? diff --git a/google-cloud-bigquery/lib/google/cloud/bigquery/query_job.rb b/google-cloud-bigquery/lib/google/cloud/bigquery/query_job.rb index eecf17ec61e0..1d7616d2674a 100644 --- a/google-cloud-bigquery/lib/google/cloud/bigquery/query_job.rb +++ b/google-cloud-bigquery/lib/google/cloud/bigquery/query_job.rb @@ -817,6 +817,51 @@ def self.from_options service, query, options updater end + ## + # @private + # Determines if the query can be run as a stateless query. + def stateless_query? + # Check denylist of settings not supported by jobs.query. + q = @gapi.configuration.query + return false if q.destination_table + return false if q.create_disposition + return false if q.write_disposition + return false if q.priority == "BATCH" + return false if q.allow_large_results + return false if q.flatten_results == false + return false if q.user_defined_function_resources && q.user_defined_function_resources.any? + return false if q.clustering + return false if q.time_partitioning + return false if q.range_partitioning + return false if q.schema_update_options + return false if q.table_definitions && q.table_definitions.any? + true + end + + ## + # @private + # Converts the current configuration to a Google::Apis::BigqueryV2::QueryRequest object. + def to_query_request_gapi + q = @gapi.configuration.query + Google::Apis::BigqueryV2::QueryRequest.new( + query: q.query, + default_dataset: q.default_dataset, + use_query_cache: q.use_query_cache, + dry_run: @gapi.configuration.dry_run, + use_legacy_sql: q.use_legacy_sql, + parameter_mode: q.parameter_mode, + query_parameters: q.query_parameters, + job_creation_mode: "JOB_CREATION_OPTIONAL", + location: @gapi.job_reference&.location, + maximum_bytes_billed: q.maximum_bytes_billed, + connection_properties: q.connection_properties, + labels: @gapi.configuration.labels, + destination_encryption_configuration: q.destination_encryption_configuration, + create_session: q.create_session, + reservation: @gapi.configuration.reservation + ) + end + ## # Sets the geographic location where the job should run. Required # except for US and EU. diff --git a/google-cloud-bigquery/lib/google/cloud/bigquery/service.rb b/google-cloud-bigquery/lib/google/cloud/bigquery/service.rb index f940286f641f..1c1d4325a51d 100644 --- a/google-cloud-bigquery/lib/google/cloud/bigquery/service.rb +++ b/google-cloud-bigquery/lib/google/cloud/bigquery/service.rb @@ -445,12 +445,32 @@ def insert_job config, location: nil end end + ## + # Inserts a BigQuery job. + # + # @param [Google::Apis::BigqueryV2::Job] query_job_gapi The job + # configuration to insert. + # + # @return [Google::Apis::BigqueryV2::Job] The inserted job. def query_job query_job_gapi execute backoff: true do service.insert_job @project, query_job_gapi end end + ## + # Runs a stateless query. + # + # @param [Google::Apis::BigqueryV2::QueryRequest] query_request_gapi The + # query request to execute. + # + # @return [Google::Apis::BigqueryV2::QueryResponse] The query response. + def query query_request_gapi + execute backoff: true do + service.query_job @project, query_request_gapi + end + end + ## # Deletes the job specified by jobId and location (required). def delete_job job_id, location: nil diff --git a/google-cloud-bigquery/test/google/cloud/bigquery/project_stateless_query_test.rb b/google-cloud-bigquery/test/google/cloud/bigquery/project_stateless_query_test.rb new file mode 100644 index 000000000000..b0790c84c5b0 --- /dev/null +++ b/google-cloud-bigquery/test/google/cloud/bigquery/project_stateless_query_test.rb @@ -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 extract of the License at +# +# https://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. + +require "helper" + +describe Google::Cloud::Bigquery::Project, :query, :mock_bigquery do + let(:query) { "SELECT name, age, score, active FROM `some_project.some_dataset.users`" } + let(:job_id) { "job_9876543210" } + + it "queries the data using stateless jobs.query" do + mock = Minitest::Mock.new + bigquery.service.mocked_service = mock + + query_request_gapi = Google::Apis::BigqueryV2::QueryRequest.new( + query: query, + use_query_cache: true, + use_legacy_sql: false + ) + + # Mock the stateless query response + query_resp_gapi = Google::Apis::BigqueryV2::QueryResponse.new( + job_complete: true, + schema: Google::Apis::BigqueryV2::TableSchema.new( + fields: [ + Google::Apis::BigqueryV2::TableFieldSchema.new(name: "name", type: "STRING"), + Google::Apis::BigqueryV2::TableFieldSchema.new(name: "age", type: "INTEGER"), + Google::Apis::BigqueryV2::TableFieldSchema.new(name: "score", type: "FLOAT"), + Google::Apis::BigqueryV2::TableFieldSchema.new(name: "active", type: "BOOLEAN") + ] + ), + rows: [ + Google::Apis::BigqueryV2::TableRow.new(f: [ + Google::Apis::BigqueryV2::TableCell.new(v: "FirstName"), + Google::Apis::BigqueryV2::TableCell.new(v: "36"), + Google::Apis::BigqueryV2::TableCell.new(v: "7.65"), + Google::Apis::BigqueryV2::TableCell.new(v: "true") + ]) + ], + total_rows: 1 + ) + + mock.expect :query_job, query_resp_gapi do |p, req| + p == project && req.query == query && req.use_query_cache == true + end + + data = bigquery.query query + mock.verify + + _(data.class).must_equal Google::Cloud::Bigquery::Data + _(data.count).must_equal 1 + _(data[0][:name]).must_equal "FirstName" + _(data[0][:age]).must_equal 36 + end + + it "falls back to stateful jobs.insert when priority is BATCH" do + mock = Minitest::Mock.new + bigquery.service.mocked_service = mock + + # When priority is BATCH, it should NOT be stateless + job_gapi = query_job_gapi query, location: nil + job_gapi.configuration.query.priority = "BATCH" + + mock.expect :insert_job, query_job_resp_gapi(query, job_id: job_id), [project, job_gapi] + mock.expect :get_job_query_results, + query_data_gapi, + [project, job_id], location: "US", max_results: 0, page_token: nil, start_index: nil, timeout_ms: nil, format_options_use_int64_timestamp: nil + mock.expect :list_table_data, + table_data_gapi.to_json, + [project, "target_dataset_id", "target_table_id"], max_results: nil, page_token: nil, start_index: nil, options: {skip_deserialization: true}, format_options_use_int64_timestamp: true + + data = bigquery.query query do |q| + q.priority = "BATCH" + end + mock.verify + _(data.class).must_equal Google::Cloud::Bigquery::Data + end +end