From bc8eeea8c711153d45e7a9491decdd9b228b6e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Andreassa?= Date: Tue, 9 Sep 2025 14:22:06 -0700 Subject: [PATCH] feat: Add method to list sessions for a database --- acceptance/data/fixtures.rb | 4 +- .../acceptance/spanner/database_test.rb | 12 ++ .../lib/google/cloud/spanner/database.rb | 36 ++++ .../lib/google/cloud/spanner/interval.rb | 2 +- .../lib/google/cloud/spanner/service.rb | 10 + .../lib/google/cloud/spanner/session/list.rb | 175 ++++++++++++++++++ .../convert/number_to_duration_test.rb | 3 +- .../cloud/spanner/database/sessions_test.rb | 99 ++++++++++ 8 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 google-cloud-spanner/lib/google/cloud/spanner/session/list.rb create mode 100644 google-cloud-spanner/test/google/cloud/spanner/database/sessions_test.rb diff --git a/acceptance/data/fixtures.rb b/acceptance/data/fixtures.rb index 540a3577..1871286b 100644 --- a/acceptance/data/fixtures.rb +++ b/acceptance/data/fixtures.rb @@ -257,7 +257,7 @@ def stuffs_random_row id = SecureRandom.int64 string: SecureRandom.hex(16), byte: File.open("acceptance/data/face.jpg", "rb"), date: Date.today + rand(-100..100), - timestamp: Time.now + rand(-60 * 60 * 24.0..60 * 60 * 24.0), + timestamp: Time.now + rand((-60 * 60 * 24.0)..(60 * 60 * 24.0)), json: { venue: "Yellow Lake", rating: 10 }, ints: rand(2..10).times.map { rand(0..1000) }, floats: rand(2..10).times.map { rand(0.0..100.0) }, @@ -268,7 +268,7 @@ def stuffs_random_row id = SecureRandom.int64 File.open("acceptance/data/landmark.jpg", "rb"), File.open("acceptance/data/logo.jpg", "rb")], dates: rand(2..10).times.map { Date.today + rand(-100..100) }, - timestamps: rand(2..10).times.map { Time.now + rand(-60 * 60 * 24.0..60 * 60 * 24.0) }, + timestamps: rand(2..10).times.map { Time.now + rand((-60 * 60 * 24.0)..(60 * 60 * 24.0)) }, json_array: [{ venue: "Green Lake", rating: 8 }, { venue: "Blue Lake", rating: 9 }] } end diff --git a/google-cloud-spanner/acceptance/spanner/database_test.rb b/google-cloud-spanner/acceptance/spanner/database_test.rb index 902d959d..7ab0a5d5 100644 --- a/google-cloud-spanner/acceptance/spanner/database_test.rb +++ b/google-cloud-spanner/acceptance/spanner/database_test.rb @@ -64,6 +64,18 @@ _(first_database).must_be_kind_of Google::Cloud::Spanner::Database end + it "lists sessions" do + database = spanner.database instance_id, $spanner_database_id + _(database).wont_be :nil? + + sessions = database.sessions + _(sessions).must_be_kind_of Google::Cloud::Spanner::Session::List + _(sessions).wont_be :empty? + sessions.each do |session| + _(session).must_be_kind_of Google::Cloud::Spanner::Session + end + end + it "creates database with pitr retention period" do skip if emulator_enabled? diff --git a/google-cloud-spanner/lib/google/cloud/spanner/database.rb b/google-cloud-spanner/lib/google/cloud/spanner/database.rb index a24e2c42..b2962f8e 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/database.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/database.rb @@ -21,6 +21,7 @@ require "google/cloud/spanner/database/restore_info" require "google/cloud/spanner/backup" require "google/cloud/spanner/policy" +require "google/cloud/spanner/session/list" module Google module Cloud @@ -618,6 +619,41 @@ def backups page_size: nil Backup::List.from_grpc grpc, service end + ## + # Retrieves sessions belonging to the database. + # + # @param [Integer] page_size Optional. Number of sessions to be returned + # in the response. If 0 or less, defaults to the server's maximum + # allowed page size. + # @return [Array] Enumerable list of + # sessions. (See {Google::Cloud::Spanner::Session::List}) + # + # @example + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # database = spanner.database "my-instance", "my-database" + # + # database.sessions.all.each do |session| + # puts session.session_id + # end + # + # @example List sessions by page size + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # database = spanner.database "my-instance", "my-database" + # + # database.sessions(page_size: 5).all.each do |session| + # puts session.session_id + # end + # + def sessions page_size: nil + ensure_service! + grpc = service.list_sessions database: path, max: page_size + Session::List.from_grpc grpc, service + end + # Information about the source used to restore the database. # # @return [Google::Cloud::Spanner::Database::RestoreInfo, nil] diff --git a/google-cloud-spanner/lib/google/cloud/spanner/interval.rb b/google-cloud-spanner/lib/google/cloud/spanner/interval.rb index eb3895ed..2589f86b 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/interval.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/interval.rb @@ -75,7 +75,7 @@ def parse interval_string (?:T(?!$) (?:(?-?\d+)H)? (?:(?-?\d+)M)? - (?:(?-?(?!S)\d*(?:[\.,]\d{1,9})?)S)?)? + (?:(?-?(?!S)\d*(?:[.,]\d{1,9})?)S)?)? $ /x interval_months = 0 diff --git a/google-cloud-spanner/lib/google/cloud/spanner/service.rb b/google-cloud-spanner/lib/google/cloud/spanner/service.rb index fcdbc057..fce08979 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/service.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/service.rb @@ -365,6 +365,16 @@ def delete_session session_name, call_options: nil service.delete_session({ name: session_name }, opts) end + def list_sessions database:, call_options: nil, token: nil, max: nil + opts = default_options call_options: call_options + request = { + database: database, + page_size: max, + page_token: token + } + service.list_sessions request, opts + end + def execute_streaming_sql session_name, sql, transaction: nil, params: nil, types: nil, resume_token: nil, partition_token: nil, seqno: nil, diff --git a/google-cloud-spanner/lib/google/cloud/spanner/session/list.rb b/google-cloud-spanner/lib/google/cloud/spanner/session/list.rb new file mode 100644 index 00000000..c351fedf --- /dev/null +++ b/google-cloud-spanner/lib/google/cloud/spanner/session/list.rb @@ -0,0 +1,175 @@ +# Copyright 2025 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 +# +# 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 "delegate" + +module Google + module Cloud + module Spanner + class Session + ## + # Session::List is a special case Array with additional + # values. + class List < DelegateClass(::Array) + ## + # @private + # The gRPC Service object. + attr_accessor :service + + ## + # @private + # The gRPC page enumerable object. + attr_accessor :grpc + + ## + # @private Create a new Session::List with an array of + # Session instances. + def initialize arr = [] + super arr + end + + ## + # Whether there is a next page of sessions. + # + # @return [Boolean] + # + # @example + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # database = spanner.database "my-instance", "my-database" + # + # sessions = database.sessions + # if sessions.next? + # next_sessions = sessions.next + # end + def next? + grpc.next_page? + end + + ## + # Retrieve the next page of sessions. + # + # @return [Session::List] + # + # @example + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # database = spanner.database "my-instance", "my-database" + # + # sessions = database.sessions + # if sessions.next? + # next_sessions = sessions.next + # end + def next + return nil unless next? + ensure_service! + grpc.next_page + self.class.from_grpc grpc, @service + end + + ## + # Retrieves remaining results by repeatedly invoking {#next} until + # {#next?} returns `false`. Calls the given block once for each + # result, which is passed as the argument to the block. + # + # An Enumerator is returned if no block is given. + # + # This method will make repeated API calls until all remaining results + # are retrieved. (Unlike `#each`, for example, which merely iterates + # over the results returned by a single API call.) Use with caution. + # + # @param [Integer] request_limit The upper limit of API requests to + # make to load all sessions. Default is no limit. + # @yield [session] The block for accessing each session. + # @yieldparam [Session] session The session object. + # + # @return [Enumerator] + # + # @example Iterating each session by passing a block: + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # database = spanner.database "my-instance", "my-database" + # + # sessions = database.sessions + # sessions.all do |session| + # puts session.session_id + # end + # + # @example Using the enumerator by not passing a block: + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # database = spanner.database "my-instance", "my-database" + # + # sessions = database.sessions + # all_session_ids = sessions.all.map do |session| + # session.session_id + # end + # + # @example Limit the number of API calls made: + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # database = spanner.database "my-instance", "my-database" + # + # sessions = database.sessions + # sessions.all(request_limit: 10) do |session| + # puts session.session_id + # end + # + def all request_limit: nil, &block + request_limit = request_limit.to_i if request_limit + unless block_given? + return enum_for :all, request_limit: request_limit + end + results = self + loop do + results.each(&block) + if request_limit + request_limit -= 1 + break if request_limit.negative? + end + break unless results.next? + results = results.next + end + end + + ## + # @private New Session::List from a + # `Google::Cloud::Spanner::V1::ListSessionsResponse` + # object. + def self.from_grpc grpc, service + sessions = List.new(Array(grpc.response.sessions).map do |session| + Session.from_grpc session, service + end) + sessions.grpc = grpc + sessions.service = service + sessions + end + + protected + + ## + # Raise an error unless an active service is available. + def ensure_service! + raise "Must have active connection" unless @service + end + end + end + end + end +end diff --git a/google-cloud-spanner/test/google/cloud/spanner/convert/number_to_duration_test.rb b/google-cloud-spanner/test/google/cloud/spanner/convert/number_to_duration_test.rb index 70d3a9d8..deaf92a2 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/convert/number_to_duration_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/convert/number_to_duration_test.rb @@ -73,8 +73,7 @@ number = BigDecimal "-643383279502884.1971693993751058209749445923078164062" duration = Google::Cloud::Spanner::Convert.number_to_duration number _(duration).must_be_kind_of Google::Protobuf::Duration - # This should really be -643383279502884, but BigDecimal is doing something here... - _(duration.seconds).must_equal -643383279502885 + _(duration.seconds).must_equal -643383279502884 _(duration.nanos).must_equal -197169399 end diff --git a/google-cloud-spanner/test/google/cloud/spanner/database/sessions_test.rb b/google-cloud-spanner/test/google/cloud/spanner/database/sessions_test.rb new file mode 100644 index 00000000..85cf4122 --- /dev/null +++ b/google-cloud-spanner/test/google/cloud/spanner/database/sessions_test.rb @@ -0,0 +1,99 @@ +# Copyright 2025 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 +# +# 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::Spanner::Database, :mock_spanner do + let(:instance_id) { "my-instance-id" } + let(:database_id) { "my-database-id" } + let(:database_grpc) do + Google::Cloud::Spanner::Admin::Database::V1::Database.new \ + database_hash(instance_id: instance_id, database_id: database_id) + end + let(:database) { Google::Cloud::Spanner::Database.from_grpc database_grpc, spanner.service } + + def sessions_hash count: 3, instance_id: "my-instance-id", database_id: "my-database-id" + sessions = count.times.map do |i| + { name: session_path(instance_id, database_id, "session-#{i}") } + end + { sessions: sessions } + end + + let(:first_page) do + h = sessions_hash instance_id: instance_id, database_id: database_id + h[:next_page_token] = "next_page_token" + Google::Cloud::Spanner::V1::ListSessionsResponse.new h + end + let(:last_page) do + h = sessions_hash instance_id: instance_id, database_id: database_id + h[:sessions].pop + Google::Cloud::Spanner::V1::ListSessionsResponse.new h + end + + it "lists sessions" do + get_sessions_resp = MockPagedEnumerable.new( + [first_page] + ) + mock = Minitest::Mock.new + mock.expect :list_sessions, get_sessions_resp, [{ database: database_path(instance_id, database_id), page_size: nil, page_token: nil }, ::Gapic::CallOptions] + database.service.mocked_service = mock + + sessions = database.sessions + + mock.verify + + _(sessions.count).must_equal 3 + sessions.each do |session| + _(session).must_be_kind_of Google::Cloud::Spanner::Session + end + end + + it "paginates sessions" do + get_sessions_resp = MockPagedEnumerable.new( + [first_page, last_page] + ) + mock = Minitest::Mock.new + mock.expect :list_sessions, get_sessions_resp, [{ database: database_path(instance_id, database_id), page_size: nil, page_token: nil }, ::Gapic::CallOptions] + database.service.mocked_service = mock + + sessions = database.sessions + + mock.verify + + _(sessions.count).must_equal 3 + sessions.each do |session| + _(session).must_be_kind_of Google::Cloud::Spanner::Session + end + _(sessions.next?).must_equal true + end + + it "paginates sessions with page size" do + get_sessions_resp = MockPagedEnumerable.new( + [first_page, last_page] + ) + mock = Minitest::Mock.new + mock.expect :list_sessions, get_sessions_resp, [{ database: database_path(instance_id, database_id), page_size: 3, page_token: nil }, ::Gapic::CallOptions] + database.service.mocked_service = mock + + sessions = database.sessions page_size: 3 + + mock.verify + + _(sessions.count).must_equal 3 + sessions.each do |session| + _(session).must_be_kind_of Google::Cloud::Spanner::Session + end + _(sessions.next?).must_equal true + end +end