Skip to content

Commit 7c3b6cb

Browse files
authored
RUBY-2525 Expose the Reason an Operation Fails Document Validation (#2379)
* RUBY-2525 add details to operation failure * RUBY-2525 add details to message and tests * RUBY-2525 update comments * RUBY-2525 remove unnecessary assignment * RUBY-2525 clean up retrieve details * RUBY-2525 augment bulk write error message and test * RUBY-2525 add check in bulkwriteerror test * RUBY-2525 fix tests for before WriteError server imp * RUBY-2525 fix broken test * RUBY-2525 fix regex in test * RUBY-2525 change condition * RUBY-2525 add comments, cleanup conditions * RUBY-2525 add bulk_write message comment * RUBY-2525 fix bulk write message comment * RUBY-2525 fix test rescue
1 parent 1c20b64 commit 7c3b6cb

File tree

4 files changed

+143
-7
lines changed

4 files changed

+143
-7
lines changed

lib/mongo/error/bulk_write_error.rb

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,42 @@ def initialize(result)
5555

5656
private
5757

58+
# Generates an error message when there are multiple write errors.
59+
#
60+
# @example Multiple documents fail validation
61+
#
62+
# col has validation { 'validator' => { 'x' => { '$type' => 'string' } } }
63+
# col.insert_many([{_id: 1}, {_id: 2}], ordered: false)
64+
#
65+
# Multiple errors:
66+
# [121]: Document failed validation --
67+
# {"failingDocumentId":1,"details":{"operatorName":"$type",
68+
# "specifiedAs":{"x":{"$type":"string"}},"reason":"field was
69+
# missing"}};
70+
# [121]: Document failed validation --
71+
# {"failingDocumentId":2, "details":{"operatorName":"$type",
72+
# "specifiedAs":{"x":{"$type":"string"}}, "reason":"field was
73+
# missing"}}
74+
#
75+
# @return [ String ] The error message
5876
def build_message
5977
errors = @result['writeErrors']
6078
return nil unless errors
6179

62-
fragment = errors.first(10).map do |error|
63-
"[#{error['code']}]: #{error['errmsg']}"
64-
end.join('; ')
80+
fragment = ""
81+
cut_short = false
82+
errors.first(10).each_with_index do |error, i|
83+
fragment += "; " if fragment.length > 0
84+
fragment += "[#{error['code']}]: #{error['errmsg']}"
85+
fragment += " -- #{error['errInfo'].to_json}" if error['errInfo']
86+
87+
if fragment.length > 3000
88+
cut_short = i < [9, errors.length].min
89+
break
90+
end
91+
end
6592

66-
fragment += '...' if errors.length > 10
93+
fragment += '...' if errors.length > 10 || cut_short
6794

6895
if errors.length > 1
6996
fragment = "Multiple errors: #{fragment}"

lib/mongo/error/operation_failure.rb

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,12 @@ def write_concern_error?
224224
# @since 2.10.0
225225
attr_reader :write_concern_error_code_name
226226

227+
# @return [ String | nil ] The details of the error.
228+
# For WriteConcernErrors this is `document['writeConcernError']['errInfo']`.
229+
# For WriteErrors this is `document['writeErrors'][0]['errInfo']`.
230+
# For all other errors this is nil.
231+
attr_reader :details
232+
227233
# @return [ BSON::Document | nil ] The server-returned error document.
228234
#
229235
# @api experimental
@@ -258,10 +264,10 @@ def write_concern_error?
258264
# @option options [ Array<String> ] :labels The set of labels associated
259265
# with the error.
260266
# @option options [ true | false ] :wtimeout Whether the error is a wtimeout.
261-
#
262-
# @since 2.5.0, options added in 2.6.0
263267
def initialize(message = nil, result = nil, options = {})
264-
super(message)
268+
@details = retrieve_details(options[:document])
269+
super(append_details(message, @details))
270+
265271
@result = result
266272
@code = options[:code]
267273
@code_name = options[:code_name]
@@ -306,6 +312,28 @@ def unsupported_retryable_write?
306312
# either having string keys or providing indifferent access.
307313
code == 20 && server_message&.start_with?("Transaction numbers") || false
308314
end
315+
316+
private
317+
318+
# Retrieve the details from a document
319+
#
320+
# @return [ Hash | nil ] the details extracted from the document
321+
def retrieve_details(document)
322+
return nil unless document
323+
if wce = document['writeConcernError']
324+
return wce['errInfo']
325+
elsif we = document['writeErrors']&.first
326+
return we['errInfo']
327+
end
328+
end
329+
330+
# Append the details to the message
331+
#
332+
# @return [ String ] the message with the details appended to it
333+
def append_details(message, details)
334+
return message unless details && message
335+
message + " -- #{details.to_json}"
336+
end
309337
end
310338
end
311339
end

spec/integration/bulk_write_error_message_spec.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,36 @@
3838
end
3939
end
4040
end
41+
42+
context 'a bulk write with validation errors' do
43+
44+
let(:collection_name) { 'bulk_write_error_validation_message_spec' }
45+
46+
let(:collection) do
47+
client[:collection_name].drop
48+
client[:collection_name,
49+
{
50+
'validator' => {
51+
'x' => { '$type' => 'string' },
52+
}
53+
}].create
54+
client[:collection_name]
55+
end
56+
57+
it 'reports code name, code, message, and details' do
58+
begin
59+
collection.insert_one({_id:1, x:"1"})
60+
collection.insert_many([
61+
{_id: 1, x:"1"},
62+
{_id: 2, x:1},
63+
], ordered: false)
64+
fail('Should have raised')
65+
rescue Mongo::Error::BulkWriteError => e
66+
e.message.should =~ %r,\AMultiple errors: \[11000\]: (insertDocument :: caused by :: 11000 )?E11000 duplicate key error (collection|index):.*\; \[121\]: Document failed validation( -- .*)?,
67+
# The duplicate key error should not print details because it's not a
68+
# WriteError or a WriteConcernError
69+
e.message.scan(/ -- /).length.should be <= 1
70+
end
71+
end
72+
end
4173
end

spec/mongo/error/operation_failure_heavy_spec.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
begin
4141
authorized_client['foo'].insert_one(test: 1)
4242
rescue Mongo::Error::OperationFailure => exc
43+
expect(exc.details).to eq(exc.document['writeConcernError']['errInfo'])
44+
expect(exc.server_message).to eq(exc.document['writeConcernError']['errmsg'])
45+
expect(exc.code).to eq(exc.document['writeConcernError']['code'])
4346
else
4447
fail 'Expected an OperationFailure'
4548
end
@@ -58,4 +61,50 @@
5861
}
5962
end
6063
end
64+
65+
describe 'WriteError details' do
66+
min_server_fcv '5.0'
67+
68+
let(:subscriber) { Mrss::EventSubscriber.new }
69+
70+
let(:subscribed_client) do
71+
authorized_client.tap do |client|
72+
client.subscribe(Mongo::Monitoring::COMMAND, subscriber)
73+
end
74+
end
75+
76+
let(:collection_name) { 'write_error_prose_spec' }
77+
78+
let(:collection) do
79+
subscribed_client[:collection_name].drop
80+
subscribed_client[:collection_name,
81+
{
82+
'validator' => {
83+
'x' => { '$type' => 'string' },
84+
}
85+
}].create
86+
subscribed_client[:collection_name]
87+
end
88+
89+
context 'when there is a write error' do
90+
it 'succeeds and prints the error' do
91+
begin
92+
collection.insert_one({x: 1})
93+
rescue Mongo::Error::OperationFailure => e
94+
insert_events = subscriber.succeeded_events.select { |e| e.command_name == "insert" }
95+
expect(insert_events.length).to eq 1
96+
expect(e.message).to match(/\[#{e.code}(:.*)?\].+ -- .+/)
97+
98+
expect(e.details).to eq(e.document['writeErrors'][0]['errInfo'])
99+
expect(e.server_message).to eq(e.document['writeErrors'][0]['errmsg'])
100+
expect(e.code).to eq(e.document['writeErrors'][0]['code'])
101+
102+
expect(e.code).to eq 121
103+
expect(e.details).to eq(insert_events[0].reply['writeErrors'][0]['errInfo'])
104+
else
105+
fail 'Expected an OperationFailure'
106+
end
107+
end
108+
end
109+
end
61110
end

0 commit comments

Comments
 (0)