Skip to content

Commit b09720f

Browse files
authored
chore(ruby): Add mock server tests (#600)
1 parent 34e9bd5 commit b09720f

File tree

5 files changed

+624
-0
lines changed

5 files changed

+624
-0
lines changed

spannerlib/wrappers/spannerlib-ruby/.rubocop.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,25 @@ RSpec/DescribeClass:
3030

3131
Style/StringLiterals:
3232
EnforcedStyle: double_quotes
33+
34+
Style/FormatStringToken:
35+
EnforcedStyle: template
36+
Metrics/ClassLength:
37+
Exclude:
38+
- 'spec/mock_server/**/*'
39+
40+
Metrics/MethodLength:
41+
Exclude:
42+
- 'spec/mock_server/**/*'
43+
44+
Metrics/AbcSize:
45+
Exclude:
46+
- 'spec/mock_server/**/*'
47+
48+
Metrics/CyclomaticComplexity:
49+
Exclude:
50+
- 'spec/mock_server/**/*'
51+
52+
Metrics/PerceivedComplexity:
53+
Exclude:
54+
- 'spec/mock_server/**/*'

spannerlib/wrappers/spannerlib-ruby/Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ gemspec
77
gem "rake", "~> 13.0"
88

99
group :development, :test do
10+
gem "minitest", "~> 5.0"
1011
gem "rake-compiler", "~> 1.0"
1112
gem "rspec", "~> 3.0"
1213
gem "rubocop", require: false
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright 2025 Google LLC
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# https://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
require_relative "statement_result"
18+
require "google/rpc/error_details_pb"
19+
require "google/spanner/v1/spanner_pb"
20+
require "google/spanner/v1/spanner_services_pb"
21+
require "google/cloud/spanner/v1/spanner"
22+
23+
require "grpc"
24+
require "gapic/grpc/service_stub"
25+
require "securerandom"
26+
27+
# Mock implementation of Spanner
28+
class SpannerMockServer < Google::Cloud::Spanner::V1::Spanner::Service
29+
attr_reader :requests
30+
31+
def initialize
32+
super
33+
@statement_results = {}
34+
@sessions = {}
35+
@transactions = {}
36+
@aborted_transactions = {}
37+
@requests = []
38+
@errors = {}
39+
@session_counter = 0
40+
41+
put_statement_result "SELECT 1", StatementResult.create_select1_result
42+
put_statement_result "INSERT INTO test_table (id, name) VALUES ('1', 'Alice')", StatementResult.create_update_count_result(1)
43+
44+
dialect_sql = "select option_value from information_schema.database_options where option_name='database_dialect'"
45+
dialect_result = StatementResult.create_dialect_result
46+
47+
put_statement_result dialect_sql, dialect_result
48+
end
49+
50+
def put_statement_result(sql, result)
51+
@statement_results[sql] = result
52+
end
53+
54+
def push_error(sql_or_method, error)
55+
@errors[sql_or_method] = [] unless @errors[sql_or_method]
56+
@errors[sql_or_method].push error
57+
end
58+
59+
def create_session(request, _unused_call)
60+
@requests << request
61+
do_create_session(request.database, request.session)
62+
end
63+
64+
def batch_create_sessions(request, _unused_call)
65+
@requests << request
66+
num_created = 0
67+
response = Google::Cloud::Spanner::V1::BatchCreateSessionsResponse.new
68+
while num_created < request.session_count
69+
response.session << do_create_session(request.database, request.session_template)
70+
num_created += 1
71+
end
72+
response
73+
end
74+
75+
def get_session(request, _unused_call)
76+
@requests << request
77+
@sessions[request.name]
78+
end
79+
80+
def list_sessions(request, _unused_call)
81+
@requests << request
82+
response = Google::Cloud::Spanner::V1::ListSessionsResponse.new
83+
@sessions.each_value do |s|
84+
response.sessions << s
85+
end
86+
response
87+
end
88+
89+
def delete_session(request, _unused_call)
90+
@requests << request
91+
@sessions.delete request.name
92+
Google::Protobuf::Empty.new
93+
end
94+
95+
def execute_sql(request, _unused_call)
96+
do_execute_sql request, false
97+
end
98+
99+
def execute_streaming_sql(request, _unused_call)
100+
do_execute_sql request, true
101+
end
102+
103+
# @private
104+
def do_execute_sql(request, streaming)
105+
@requests << request
106+
validate_session request.session
107+
created_transaction = do_create_transaction request.session if request.transaction&.begin
108+
transaction_id = created_transaction&.id || request.transaction&.id
109+
validate_transaction request.session, transaction_id if transaction_id && transaction_id != ""
110+
111+
raise @errors[request.sql].pop if @errors[request.sql] && !@errors[request.sql].empty?
112+
113+
result = get_statement_result(request.sql).clone
114+
raise result.result if result.result_type == StatementResult::EXCEPTION
115+
116+
if streaming
117+
result.each created_transaction
118+
else
119+
result.result created_transaction
120+
end
121+
end
122+
123+
def execute_batch_dml(request, _unused_call)
124+
@requests << request
125+
validate_session request.session
126+
created_transaction = do_create_transaction request.session if request.transaction&.begin
127+
transaction_id = created_transaction&.id || request.transaction&.id
128+
validate_transaction request.session, transaction_id if transaction_id && transaction_id != ""
129+
130+
status = Google::Rpc::Status.new
131+
response = Google::Cloud::Spanner::V1::ExecuteBatchDmlResponse.new
132+
first = true
133+
request.statements.each do |stmt|
134+
result = get_statement_result(stmt.sql).clone
135+
if result.result_type == StatementResult::EXCEPTION
136+
status.code = result.result.code
137+
status.message = result.result.message
138+
break
139+
end
140+
if first
141+
response.result_sets << result.result(created_transaction)
142+
first = false
143+
else
144+
response.result_sets << result.result
145+
end
146+
end
147+
response.status = status
148+
response
149+
end
150+
151+
def read(request, _unused_call)
152+
@requests << request
153+
raise GRPC::BadStatus.new GRPC::Core::StatusCodes::UNIMPLEMENTED, "Not yet implemented"
154+
end
155+
156+
def streaming_read(request, _unused_call)
157+
@requests << request
158+
raise GRPC::BadStatus.new GRPC::Core::StatusCodes::UNIMPLEMENTED, "Not yet implemented"
159+
end
160+
161+
def begin_transaction(request, _unused_call)
162+
@requests << request
163+
raise @errors[__method__.to_s].pop if @errors[__method__.to_s] && !@errors[__method__.to_s].empty?
164+
165+
validate_session request.session
166+
do_create_transaction request.session
167+
end
168+
169+
def commit(request, _unused_call)
170+
@requests << request
171+
validate_session request.session
172+
validate_transaction request.session, request.transaction_id
173+
Google::Cloud::Spanner::V1::CommitResponse.new commit_timestamp: Google::Protobuf::Timestamp.new(seconds: Time.now.to_i)
174+
end
175+
176+
def rollback(request, _unused_call)
177+
@requests << request
178+
validate_session request.session
179+
name = "#{request.session}/transactions/#{request.transaction_id}"
180+
@transactions.delete name
181+
Google::Protobuf::Empty.new
182+
end
183+
184+
def partition_query(request, _unused_call)
185+
@requests << request
186+
raise GRPC::BadStatus.new GRPC::Core::StatusCodes::UNIMPLEMENTED, "Not yet implemented"
187+
end
188+
189+
def partition_read(request, _unused_call)
190+
@requests << request
191+
raise GRPC::BadStatus.new GRPC::Core::StatusCodes::UNIMPLEMENTED, "Not yet implemented"
192+
end
193+
194+
def get_database(request, _unused_call)
195+
@requests << request
196+
raise GRPC::BadStatus.new GRPC::Core::StatusCodes::UNIMPLEMENTED, "Not yet implemented"
197+
end
198+
199+
def abort_transaction(session, id)
200+
return if session.nil? || id.nil?
201+
202+
name = "#{session}/transactions/#{id}"
203+
@aborted_transactions[name] = true
204+
end
205+
206+
def abort_next_transaction
207+
@abort_next_transaction = true
208+
end
209+
210+
def get_statement_result(sql)
211+
unless @statement_results.key? sql
212+
@statement_results.each do |key, value|
213+
return value if key.end_with?("%") && sql.start_with?(key.chop)
214+
end
215+
raise GRPC::BadStatus.new(
216+
GRPC::Core::StatusCodes::INVALID_ARGUMENT,
217+
"There's no result registered for #{sql}"
218+
)
219+
end
220+
@statement_results[sql]
221+
end
222+
223+
def delete_all_sessions
224+
@sessions.clear
225+
end
226+
227+
private
228+
229+
def validate_session(session)
230+
return if @sessions.key? session
231+
232+
resource_info = Google::Rpc::ResourceInfo.new(
233+
resource_type: "type.googleapis.com/google.spanner.v1.Session",
234+
resource_name: session
235+
)
236+
raise GRPC::BadStatus.new(
237+
GRPC::Core::StatusCodes::NOT_FOUND,
238+
"Session not found: Session with id #{session} not found",
239+
{ "google.rpc.resourceinfo-bin": Google::Rpc::ResourceInfo.encode(resource_info) }
240+
)
241+
end
242+
243+
def do_create_session(database, session_template = nil)
244+
@session_counter += 1
245+
name = "#{database}/sessions/mock-session-#{@session_counter}"
246+
session = Google::Cloud::Spanner::V1::Session.new(name: name)
247+
248+
session.multiplexed = session_template.multiplexed if session_template
249+
250+
@sessions[name] = session
251+
session
252+
end
253+
254+
def validate_transaction(session, transaction)
255+
name = "#{session}/transactions/#{transaction}"
256+
unless @transactions.key? name
257+
raise GRPC::BadStatus.new(
258+
GRPC::Core::StatusCodes::NOT_FOUND,
259+
"Transaction not found: Transaction with id #{transaction} not found"
260+
)
261+
end
262+
return unless @aborted_transactions.key?(name)
263+
264+
retry_info = Google::Rpc::RetryInfo.new(retry_delay: Google::Protobuf::Duration.new(seconds: 0, nanos: 1))
265+
raise GRPC::BadStatus.new(
266+
GRPC::Core::StatusCodes::ABORTED,
267+
"Transaction aborted",
268+
{ "google.rpc.retryinfo-bin": Google::Rpc::RetryInfo.encode(retry_info) }
269+
)
270+
end
271+
272+
def do_create_transaction(session)
273+
id = SecureRandom.uuid
274+
name = "#{session}/transactions/#{id}"
275+
transaction = Google::Cloud::Spanner::V1::Transaction.new id: id
276+
@transactions[name] = transaction
277+
if @abort_next_transaction
278+
abort_transaction session, id
279+
@abort_next_transaction = false
280+
end
281+
transaction
282+
end
283+
end

0 commit comments

Comments
 (0)