Skip to content

Commit c7d8fbf

Browse files
committed
chore(ruby): Add support for dynamic mock server tests
1 parent 1b35626 commit c7d8fbf

File tree

5 files changed

+300
-114
lines changed

5 files changed

+300
-114
lines changed

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ RSpec/BeforeAfterAll:
2626
Enabled: false
2727
RSpec/DescribeClass:
2828
Exclude:
29-
- 'spec/integration/**/*'
29+
- 'spec/**/*'
3030

3131
Style/StringLiterals:
3232
EnforcedStyle: double_quotes
@@ -35,20 +35,20 @@ Style/FormatStringToken:
3535
EnforcedStyle: template
3636
Metrics/ClassLength:
3737
Exclude:
38-
- 'spec/mock_server/**/*'
38+
- 'spec/**/*'
3939

4040
Metrics/MethodLength:
4141
Exclude:
42-
- 'spec/mock_server/**/*'
42+
- 'spec/**/*'
4343

4444
Metrics/AbcSize:
4545
Exclude:
46-
- 'spec/mock_server/**/*'
46+
- 'spec/**/*'
4747

4848
Metrics/CyclomaticComplexity:
4949
Exclude:
50-
- 'spec/mock_server/**/*'
50+
- 'spec/**/*'
5151

5252
Metrics/PerceivedComplexity:
5353
Exclude:
54-
- 'spec/mock_server/**/*'
54+
- 'spec/**/*'

spannerlib/wrappers/spannerlib-ruby/spec/mock_server/spanner_mock_server.rb

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,28 @@ def initialize
3838
@errors = {}
3939
@session_counter = 0
4040

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)
41+
@mock_file_path = ENV.fetch("MOCK_MSG_FILE", nil)
42+
@req_file_path = ENV.fetch("MOCK_REQ_FILE", nil)
4343

44-
dialect_sql = "select option_value from information_schema.database_options where option_name='database_dialect'"
45-
dialect_result = StatementResult.create_dialect_result
44+
add_default_results
45+
end
4646

47-
put_statement_result dialect_sql, dialect_result
47+
def log_request(request)
48+
@requests << request # Keep memory copy just in case
49+
return unless @req_file_path
50+
51+
# We must manually serialize the Protobuf before Marshaling
52+
data = {
53+
class: request.class.name,
54+
payload: request.class.encode(request)
55+
}
56+
57+
# Append to file using atomic open
58+
File.open(@req_file_path, "ab") do |f|
59+
Marshal.dump(data, f)
60+
end
61+
rescue StandardError => e
62+
warn "Failed to log request: #{e.message}"
4863
end
4964

5065
def put_statement_result(sql, result)
@@ -57,12 +72,12 @@ def push_error(sql_or_method, error)
5772
end
5873

5974
def create_session(request, _unused_call)
60-
@requests << request
75+
log_request(request)
6176
do_create_session(request.database, request.session)
6277
end
6378

6479
def batch_create_sessions(request, _unused_call)
65-
@requests << request
80+
log_request(request)
6681
num_created = 0
6782
response = Google::Cloud::Spanner::V1::BatchCreateSessionsResponse.new
6883
while num_created < request.session_count
@@ -73,12 +88,12 @@ def batch_create_sessions(request, _unused_call)
7388
end
7489

7590
def get_session(request, _unused_call)
76-
@requests << request
91+
log_request(request)
7792
@sessions[request.name]
7893
end
7994

8095
def list_sessions(request, _unused_call)
81-
@requests << request
96+
log_request(request)
8297
response = Google::Cloud::Spanner::V1::ListSessionsResponse.new
8398
@sessions.each_value do |s|
8499
response.sessions << s
@@ -87,16 +102,18 @@ def list_sessions(request, _unused_call)
87102
end
88103

89104
def delete_session(request, _unused_call)
90-
@requests << request
105+
log_request(request)
91106
@sessions.delete request.name
92107
Google::Protobuf::Empty.new
93108
end
94109

95110
def execute_sql(request, _unused_call)
111+
log_request(request)
96112
do_execute_sql request, false
97113
end
98114

99115
def execute_streaming_sql(request, _unused_call)
116+
log_request(request)
100117
do_execute_sql request, true
101118
end
102119

@@ -111,7 +128,13 @@ def do_execute_sql(request, streaming)
111128
raise @errors[request.sql].pop if @errors[request.sql] && !@errors[request.sql].empty?
112129

113130
result = get_statement_result(request.sql).clone
114-
raise result.result if result.result_type == StatementResult::EXCEPTION
131+
132+
if result.result_type == StatementResult::EXCEPTION
133+
raise GRPC::BadStatus.new(result.result.code, result.result.message) if result.result.is_a?(Google::Rpc::Status)
134+
135+
raise result.result
136+
137+
end
115138

116139
if streaming
117140
result.each created_transaction
@@ -121,6 +144,7 @@ def do_execute_sql(request, streaming)
121144
end
122145

123146
def execute_batch_dml(request, _unused_call)
147+
log_request(request)
124148
@requests << request
125149
validate_session request.session
126150
created_transaction = do_create_transaction request.session if request.transaction&.begin
@@ -133,8 +157,14 @@ def execute_batch_dml(request, _unused_call)
133157
request.statements.each do |stmt|
134158
result = get_statement_result(stmt.sql).clone
135159
if result.result_type == StatementResult::EXCEPTION
136-
status.code = result.result.code
137-
status.message = result.result.message
160+
err_proto = result.result
161+
if err_proto.is_a?(Google::Rpc::Status)
162+
status.code = err_proto.code
163+
status.message = err_proto.message
164+
else
165+
status.code = GRPC::Core::StatusCodes::UNKNOWN
166+
status.message = err_proto.to_s
167+
end
138168
break
139169
end
140170
if first
@@ -149,16 +179,19 @@ def execute_batch_dml(request, _unused_call)
149179
end
150180

151181
def read(request, _unused_call)
182+
log_request(request)
152183
@requests << request
153184
raise GRPC::BadStatus.new GRPC::Core::StatusCodes::UNIMPLEMENTED, "Not yet implemented"
154185
end
155186

156187
def streaming_read(request, _unused_call)
188+
log_request(request)
157189
@requests << request
158190
raise GRPC::BadStatus.new GRPC::Core::StatusCodes::UNIMPLEMENTED, "Not yet implemented"
159191
end
160192

161193
def begin_transaction(request, _unused_call)
194+
log_request(request)
162195
@requests << request
163196
raise @errors[__method__.to_s].pop if @errors[__method__.to_s] && !@errors[__method__.to_s].empty?
164197

@@ -167,13 +200,15 @@ def begin_transaction(request, _unused_call)
167200
end
168201

169202
def commit(request, _unused_call)
203+
log_request(request)
170204
@requests << request
171205
validate_session request.session
172206
validate_transaction request.session, request.transaction_id
173207
Google::Cloud::Spanner::V1::CommitResponse.new commit_timestamp: Google::Protobuf::Timestamp.new(seconds: Time.now.to_i)
174208
end
175209

176210
def rollback(request, _unused_call)
211+
log_request(request)
177212
@requests << request
178213
validate_session request.session
179214
name = "#{request.session}/transactions/#{request.transaction_id}"
@@ -182,16 +217,19 @@ def rollback(request, _unused_call)
182217
end
183218

184219
def partition_query(request, _unused_call)
220+
log_request(request)
185221
@requests << request
186222
raise GRPC::BadStatus.new GRPC::Core::StatusCodes::UNIMPLEMENTED, "Not yet implemented"
187223
end
188224

189225
def partition_read(request, _unused_call)
226+
log_request(request)
190227
@requests << request
191228
raise GRPC::BadStatus.new GRPC::Core::StatusCodes::UNIMPLEMENTED, "Not yet implemented"
192229
end
193230

194231
def get_database(request, _unused_call)
232+
log_request(request)
195233
@requests << request
196234
raise GRPC::BadStatus.new GRPC::Core::StatusCodes::UNIMPLEMENTED, "Not yet implemented"
197235
end
@@ -208,18 +246,50 @@ def abort_next_transaction
208246
end
209247

210248
def get_statement_result(sql)
249+
load_dynamic_mocks!
250+
211251
unless @statement_results.key? sql
212252
@statement_results.each do |key, value|
213253
return value if key.end_with?("%") && sql.start_with?(key.chop)
214254
end
255+
available_keys = @statement_results.keys.join(", ")
215256
raise GRPC::BadStatus.new(
216257
GRPC::Core::StatusCodes::INVALID_ARGUMENT,
217-
"There's no result registered for #{sql}"
258+
"No result registered for '#{sql}'. Available: [#{available_keys}]"
218259
)
219260
end
220261
@statement_results[sql]
221262
end
222263

264+
def load_dynamic_mocks!
265+
return unless @mock_file_path && File.exist?(@mock_file_path)
266+
267+
begin
268+
# Read binary data from file
269+
content = File.binread(@mock_file_path)
270+
return if content.empty?
271+
272+
# Deserialize the Ruby Hash containing new mocks
273+
# rubocop:disable Security/MarshalLoad
274+
new_mocks = Marshal.load(content)
275+
# rubocop:enable Security/MarshalLoad
276+
277+
# Merge into existing results
278+
@statement_results.merge!(new_mocks)
279+
rescue StandardError => e
280+
# Ignore read errors (race conditions during writing)
281+
warn "Failed to load mocks: #{e.message}"
282+
end
283+
end
284+
285+
def add_default_results
286+
put_statement_result "SELECT 1", StatementResult.create_select1_result
287+
put_statement_result "INSERT INTO test_table (id, name) VALUES ('1', 'Alice')", StatementResult.create_update_count_result(1)
288+
289+
dialect_sql = "select option_value from information_schema.database_options where option_name='database_dialect'"
290+
put_statement_result dialect_sql, StatementResult.create_dialect_result
291+
end
292+
223293
def delete_all_sessions
224294
@sessions.clear
225295
end

spannerlib/wrappers/spannerlib-ruby/spec/mock_server/statement_result.rb

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,72 @@ class StatementResult
2222
UPDATE_COUNT = 2
2323
EXCEPTION = 3
2424

25+
# -------------------------------------------------------------------------
26+
# CUSTOM SERIALIZATION LOGIC
27+
# -------------------------------------------------------------------------
28+
# This method is called by Marshal.dump
29+
def marshal_dump
30+
payload = @result
31+
class_name = nil
32+
33+
# If the result is a Protobuf, encode it to a binary string
34+
if defined?(Google::Protobuf::MessageExts) && @result.is_a?(Google::Protobuf::MessageExts)
35+
payload = @result.class.encode(@result)
36+
class_name = @result.class.name
37+
end
38+
39+
# We return an array of data we want to save
40+
[@result_type, payload, class_name]
41+
end
42+
43+
# This method is called by Marshal.load
44+
def marshal_load(array)
45+
type, payload, class_name = array
46+
@result_type = type
47+
48+
if class_name
49+
# It was a Protobuf! Rehydrate it from the string.
50+
klass = Object.const_get(class_name)
51+
@result = klass.decode(payload)
52+
else
53+
# It was a primitive (Integer, Exception, etc.)
54+
@result = payload
55+
end
56+
end
57+
58+
def self.create_single_value_result(col_name, col_type_code, value, value_type_field)
59+
# 1. Define Metadata (Column Name and Type)
60+
metadata = Google::Cloud::Spanner::V1::ResultSetMetadata.new(
61+
row_type: Google::Cloud::Spanner::V1::StructType.new(
62+
fields: [
63+
Google::Cloud::Spanner::V1::StructType::Field.new(
64+
name: col_name,
65+
type: Google::Cloud::Spanner::V1::Type.new(code: col_type_code)
66+
)
67+
]
68+
)
69+
)
70+
71+
# 2. Define Row Data
72+
# value_type_field is usually :string_value, :bool_value, etc.
73+
row = Google::Protobuf::ListValue.new(
74+
values: [Google::Protobuf::Value.new(value_type_field => value)]
75+
)
76+
77+
# 3. Wrap in ResultSet
78+
result_set = Google::Cloud::Spanner::V1::ResultSet.new(
79+
metadata: metadata,
80+
rows: [row]
81+
)
82+
83+
new(result_set, VALID)
84+
end
85+
86+
def self.create_exception_result(error_proto)
87+
# error_proto should be a Google::Rpc::Status object
88+
new(error_proto, EXCEPTION)
89+
end
90+
2591
def self.create_select1_result
2692
create_single_int_result_set "Col1", 1
2793
end
@@ -132,7 +198,12 @@ def self.random_value_or_null(value, null_fraction_divisor)
132198

133199
attr_reader :result_type
134200

135-
def initialize(result)
201+
def initialize(result, explicit_type = nil)
202+
if explicit_type
203+
@result = result
204+
@result_type = explicit_type
205+
return
206+
end
136207
case result
137208
when Google::Cloud::Spanner::V1::ResultSet
138209
@result_type = QUERY

0 commit comments

Comments
 (0)