lumberjack_capture_device 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be90765c9b26b0ca9c501504e83c2a58cf1f2ce74edffe0b5e3d8e2515194277
4
- data.tar.gz: '0825d9fa6621df4c5cdea31de794e1c8f263249d3f42f0e17945806ccb7f5f69'
3
+ metadata.gz: 3de3ac7d3bf941c8f6283c70f8bf55163eb36f91bea583836f296e7c5fa2cc3a
4
+ data.tar.gz: df6a39ef765975ff2f029d99e46aaef435c7675be707ea8effa08fc6a7369308
5
5
  SHA512:
6
- metadata.gz: 5423feff017c2b44974d7e75268c39388353165128632d0b7533619b5271ff17aeb1e9250d7ceda017de56f272baf0bda9b6e2a180a98397b13520b128f5449b
7
- data.tar.gz: 3bbdb6b6ca43683a3ec20ead94fa93e557b33194c4ee58d5f36c478e50b01e7ff5abed29a3001fce885f47f304f62d3bcbeec5cddd00708401f377db7d775bf1
6
+ metadata.gz: 8c08cdc46f06da187f942b449a0a15551aefd2c55891cbb93d17ab3b0c8f281e169bddc340bb43a9a6fdfb11aeb6cc43a4a639f3417ab3819bea002d91515e3c
7
+ data.tar.gz: dfa277bb83a2710a9df87f893a35eaf58c266c8eb1d73e00be6a4f8fb1cf6233f7f552a86329b84e85459de8b87963143197d46e07d47f4136a605f793f0136b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 2.1.0
8
+
9
+ ### Changed
10
+
11
+ - `include?` now symbolizes filter keys and raises an `ArgumentError` on unrecognized filter keys. Previously filters with string keys or misspelled keys were silently ignored, causing the method to match any captured entry.
12
+ - `write_to_underlying_device` accepts an optional `attributes` keyword argument to add attributes to each entry as it is written.
13
+ - The `include_log_entry` matcher failure message now shows a diff of the closest matching entry against the expectation instead of just printing the entry. Severity, message, progname, and each attribute are shown with the expected value on a `-` line and the logged value on a `+` line so it is obvious which ones prevented the match. The comparison is done with `Lumberjack::LogEntryMatcher#diff` using the device's entry formatter, so the diff always agrees with the match. The diff is also available directly from `Lumberjack::CaptureDevice::IncludeLogEntryMatcher#entry_diff`.
14
+
15
+ ### Fixed
16
+
17
+ - `extract` now matches filters with the device `entry_formatter` like `include?`, `match`, and `closest_match` do. Previously filter values were only compared to the formatted values captured on the entries, so unformatted values in the filters did not match.
18
+ - `capture_logger_around_example` now correctly adds the rspec metadata attributes to entries written to the underlying device when an example fails. Previously the attributes were silently dropped and building them raised a `NoMethodError` since `RSpec::Core::Example` does not have a `source_location` method; the example location is now recorded in the `rspec.location` attribute.
19
+ - The `max_entries` option is no longer ignored when initializing a `Lumberjack::CaptureDevice`.
20
+ - Fixed missing line break after "Closest match found:" in RSpec matcher failure messages.
21
+ - The `include_log_entry` matcher description no longer omits the expected attributes when a matcher (i.e. RSpec's `hash_including`) is passed as the `attributes` option instead of a hash. Matcher objects are now rendered with their description rather than by inspecting them.
22
+ - `each` and `length` now read from a thread safe copy of the entries buffer.
23
+
24
+ ### Removed
25
+
26
+ - Removed the deprecated `:level` and `:tags` options from the matching methods (`include?`, `match`, `closest_match`, and `extract`) and from the `include_log_entry` RSpec matcher. Use `:severity` and `:attributes` instead.
27
+ - Removed the `match` and `closest_match` methods. They only existed to translate the deprecated options and are now inherited unchanged from `Lumberjack::Device::Test`.
28
+ - Removed unused internal `Lumberjack::CaptureDevice::EntryScore` class. It was never loaded and was replaced by the scoring logic in the lumberjack gem.
29
+
7
30
  ## 2.0.0
8
31
 
9
32
  ### Added
@@ -17,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
40
 
18
41
  ### Deprecated
19
42
 
20
- - The `:level` and `:tags` options on the matching methods (`include?`, `match`, `closest_match`, and `extract`) has been deprecated in favor of `:severity` and `:attributes`.
43
+ - The `:level` and `:tags` options on the matching methods (`include?`, `match`, `closest_match`, and `extract`) have been deprecated in favor of `:severity` and `:attributes`.
21
44
 
22
45
  ## 1.2.2
23
46
 
data/README.md CHANGED
@@ -26,7 +26,7 @@ You can use the `include?` method on the log device to determine if specific log
26
26
  ```ruby
27
27
  Lumberjack::CaptureDevice.capture(Rails.logger) do |logs|
28
28
  do_something
29
- expect(logs).to include(level: :info, message: "Something happened")
29
+ expect(logs).to include(severity: :info, message: "Something happened")
30
30
  end
31
31
  ```
32
32
 
@@ -34,30 +34,30 @@ You can also write that same test as:
34
34
 
35
35
  ```ruby
36
36
  logs = Lumberjack::CaptureDevice.capture(Rails.logger) { do_something }
37
- expect(logs).to include(level: :info, message: "Something happened")
37
+ expect(logs).to include(severity: :info, message: "Something happened")
38
38
  ```
39
39
 
40
40
  For MiniTest, you could assert:
41
41
 
42
42
  ```ruby
43
43
  logs = Lumberjack::CaptureDevice.capture(Rails.logger) { do_something }
44
- assert(logs.include?(level: :info, message: "Something happened"))
44
+ assert(logs.include?(severity: :info, message: "Something happened"))
45
45
  ```
46
46
 
47
- You can filter the logs on level, message, and attributes.
47
+ You can filter the logs on severity, message, progname, and attributes.
48
48
 
49
- - The level option can take either a label (i.e. `:warn`) or a constant (i.e. `Logger::WARN`).
49
+ - The severity option can take either a label (i.e. `:warn`) or a constant (i.e. `Logger::WARN`).
50
50
  - The message filter can be either an exact string or a regular expression, or any matcher supported by your test library.
51
51
  - The attributes argument can match attributes with a Hash mapping attribute names to the matcher values. If attributes are nested, you can use dot notation on attribute names to reference nested attributes.
52
52
 
53
53
  ```ruby
54
- expect(logs).to include(level: :info, message: /something/i)
55
- expect(logs).to include(level: Logger::INFO, attributes: {foo: "bar"})
54
+ expect(logs).to include(severity: :info, message: /something/i)
55
+ expect(logs).to include(severity: Logger::INFO, attributes: {foo: "bar"})
56
56
  expect(logs).to include(attributes: {foo: anything, count: {one: 1}})
57
57
  expect(logs).to include(attributes: {foo: anything, "count.one" => 1})
58
58
  ```
59
59
 
60
- You can also use the `Lumberjack::CaptureDevice#extract` method with the same arguments as used by `include?` to extract all log entries that match the filters. You can get all of the log entries with `Lumberjack::CaptureDevice#buffer`.
60
+ You can also use the `Lumberjack::CaptureDevice#extract` method with the same arguments as used by `include?` to extract all log entries that match the filters. You can get all of the log entries with `Lumberjack::CaptureDevice#entries`.
61
61
 
62
62
  ### RSpec Support
63
63
 
@@ -78,6 +78,26 @@ describe MyClass do
78
78
  end
79
79
  ```
80
80
 
81
+ When the matcher fails, the failure message includes a diff between the expectation and the closest matching entry so you can see exactly which fields and attributes are off. Lines prefixed with `-` show what was expected and lines prefixed with `+` show what was actually logged.
82
+
83
+ ```
84
+ expected logs to include entry:
85
+ severity: WARN
86
+ message: User logged out
87
+ attributes: request_id: "abc"
88
+ user.role: "admin"
89
+
90
+ Closest match found (- expected, + actual):
91
+ - severity: WARN
92
+ + severity: INFO
93
+ message: "User logged out"
94
+ - attributes.user.role: "admin"
95
+ + attributes.user.role: "guest"
96
+ attributes.duration: 1.5
97
+ - attributes.request_id: "abc"
98
+ + attributes.request_id: (not set)
99
+ ```
100
+
81
101
  You can also set up log capturing around each example with the `capture_logger_around_example` method.
82
102
 
83
103
  ```ruby
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.0.0
1
+ 2.1.0
@@ -2,10 +2,16 @@
2
2
 
3
3
  # RSpec matcher for checking captured logs for specific entries.
4
4
  class Lumberjack::CaptureDevice::IncludeLogEntryMatcher
5
+ # Displayed as the actual value for an expected attribute that the log entry does not have.
6
+ MISSING_VALUE = "(not set)"
7
+
8
+ private_constant :MISSING_VALUE
9
+
5
10
  # Initialize the matcher with expected log entry attributes.
6
11
  #
7
12
  # @param expected_hash [Hash] Expected log entry attributes to match against.
8
13
  def initialize(expected_hash)
14
+ # The keys are symbolized so the hash can be passed to any Lumberjack::Device::Test device.
9
15
  @expected_hash = expected_hash.transform_keys(&:to_sym)
10
16
  @logger = nil
11
17
  end
@@ -19,7 +25,6 @@ class Lumberjack::CaptureDevice::IncludeLogEntryMatcher
19
25
  @logger = actual
20
26
  return false unless valid_logger?
21
27
 
22
- device = @logger.is_a?(Lumberjack::Device::Test) ? @logger : @logger.device
23
28
  device.include?(@expected_hash)
24
29
  end
25
30
 
@@ -28,7 +33,7 @@ class Lumberjack::CaptureDevice::IncludeLogEntryMatcher
28
33
  # @return [String] A formatted failure message.
29
34
  def failure_message
30
35
  if valid_logger?
31
- formatted_failure_message(@logger, @expected_hash)
36
+ formatted_failure_message(@expected_hash)
32
37
  else
33
38
  wrong_object_type_message(@logger)
34
39
  end
@@ -39,7 +44,7 @@ class Lumberjack::CaptureDevice::IncludeLogEntryMatcher
39
44
  # @return [String] A formatted failure message for negated expectations.
40
45
  def failure_message_when_negated
41
46
  if valid_logger?
42
- formatted_negated_failure_message(@logger, @expected_hash)
47
+ formatted_negated_failure_message(@expected_hash)
43
48
  else
44
49
  wrong_object_type_message(@logger)
45
50
  end
@@ -52,6 +57,30 @@ class Lumberjack::CaptureDevice::IncludeLogEntryMatcher
52
57
  "have logged entry with #{expectation_description(@expected_hash)}"
53
58
  end
54
59
 
60
+ # Generate a diff between a log entry and the expected log entry values. The severity,
61
+ # message, progname, and attributes of the entry are listed. Values that don't match the
62
+ # expectation are shown on a pair of lines with the expected value prefixed with "-" and
63
+ # the value from the log entry prefixed with "+". This is intended to make it easy to spot
64
+ # exactly which fields kept an entry from matching.
65
+ #
66
+ # The comparison is done by Lumberjack::LogEntryMatcher#diff so the diff always agrees
67
+ # with the result of the match.
68
+ #
69
+ # @param entry [Lumberjack::LogEntry] The log entry to compare to the expectation.
70
+ # @param indent [Integer] The number of spaces to indent each line.
71
+ # @return [String] A formatted diff of the entry and the expectation.
72
+ def entry_diff(entry, indent: 2)
73
+ indent_str = " " * indent
74
+
75
+ entry_differences(entry).collect do |name, expected, actual, matched|
76
+ if matched
77
+ "#{indent_str} #{name}: #{actual}"
78
+ else
79
+ "#{indent_str}- #{name}: #{expected}#{Lumberjack::LINE_SEPARATOR}#{indent_str}+ #{name}: #{actual}"
80
+ end
81
+ end.join(Lumberjack::LINE_SEPARATOR)
82
+ end
83
+
55
84
  private
56
85
 
57
86
  # Check if the logger is using a valid Lumberjack::Device::Test device.
@@ -79,27 +108,16 @@ class Lumberjack::CaptureDevice::IncludeLogEntryMatcher
79
108
 
80
109
  # Generate a detailed failure message showing expected vs actual logs.
81
110
  #
82
- # @param logger_or_device [Lumberjack::Device::Test] The logger device.
83
111
  # @param expected_hash [Hash] The expected log entry attributes.
84
112
  # @return [String] A formatted failure message with context.
85
- def formatted_failure_message(logger_or_device, expected_hash)
86
- device = logger_or_device.respond_to?(:device) ? logger_or_device.device : logger_or_device
87
-
88
- # Handle deprecated keys
89
- if expected_hash.include?(:level) && !expected_hash.include?(:severity)
90
- expected_hash = expected_hash.merge(severity: expected_hash[:level])
91
- end
92
- if expected_hash.include?(:tags) && !expected_hash.include?(:attributes)
93
- expected_hash = expected_hash.merge(attributes: expected_hash[:tags])
94
- end
95
-
113
+ def formatted_failure_message(expected_hash)
96
114
  message = +"expected logs to include entry:\n" \
97
115
  "#{Lumberjack::Device::Test.formatted_expectation(expected_hash, indent: 2)}"
98
116
 
99
117
  closest_match = device.closest_match(**expected_hash)
100
118
  if closest_match
101
- message << "\n\nClosest match found:" \
102
- "#{Lumberjack::Device::Test.formatted_expectation(closest_match, indent: 2)}"
119
+ message << "\n\nClosest match found (- expected, + actual):\n" \
120
+ "#{entry_diff(closest_match, indent: 2)}"
103
121
  end
104
122
 
105
123
  entries = device.entries
@@ -117,11 +135,9 @@ class Lumberjack::CaptureDevice::IncludeLogEntryMatcher
117
135
 
118
136
  # Generate a failure message for negated expectations.
119
137
  #
120
- # @param logger_or_device [Lumberjack::Device::Test] The logger to check.
121
138
  # @param expected_hash [Hash] The expected log entry attributes that should not be present.
122
139
  # @return [String] A formatted failure message for negated expectations.
123
- def formatted_negated_failure_message(logger_or_device, expected_hash)
124
- device = logger_or_device.respond_to?(:device) ? logger_or_device.device : logger_or_device
140
+ def formatted_negated_failure_message(expected_hash)
125
141
  message = "expected logs not to include entry:\n" \
126
142
  "#{Lumberjack::Device::Test.formatted_expectation(expected_hash, indent: 2)}"
127
143
 
@@ -140,14 +156,133 @@ class Lumberjack::CaptureDevice::IncludeLogEntryMatcher
140
156
  # @return [String] A formatted description of the expected attributes.
141
157
  def expectation_description(expected_hash)
142
158
  info = []
143
- info << "severity: #{expected_hash[:severity].inspect}" unless expected_hash[:severity].nil?
144
- info << "message: #{expected_hash[:message].inspect}" unless expected_hash[:message].nil?
145
- info << "progname: #{expected_hash[:progname].inspect}" unless expected_hash[:progname].nil?
146
- if expected_hash[:attributes].is_a?(Hash) && !expected_hash[:attributes].empty?
147
- attributes = Lumberjack::Utils.flatten_attributes(expected_hash[:attributes])
148
- attributes_info = attributes.collect { |name, value| "#{name}=#{value.inspect}" }.join(", ")
149
- info << "attributes: #{attributes_info}"
159
+ info << "severity: #{formatted_value(expected_hash[:severity])}" unless expected_hash[:severity].nil?
160
+ info << "message: #{formatted_value(expected_hash[:message])}" unless expected_hash[:message].nil?
161
+ info << "progname: #{formatted_value(expected_hash[:progname])}" unless expected_hash[:progname].nil?
162
+
163
+ expected_attributes = expected_hash[:attributes]
164
+ if expected_attributes.is_a?(Hash)
165
+ unless expected_attributes.empty?
166
+ attributes = Lumberjack::Utils.flatten_attributes(expected_attributes)
167
+ attributes_info = attributes.collect { |name, value| "#{name}=#{formatted_value(value)}" }.join(", ")
168
+ info << "attributes: #{attributes_info}"
169
+ end
170
+ elsif expected_attributes
171
+ # Matchers like RSpec's hash_including are matched against the attributes hash as a whole.
172
+ info << "attributes: #{formatted_value(expected_attributes)}"
150
173
  end
174
+
151
175
  info.join(", ")
152
176
  end
177
+
178
+ # Build the matcher used to compare log entries to the expectation. The entry formatter
179
+ # from the device is used so filter values are matched the same way they are by the
180
+ # device itself.
181
+ #
182
+ # @return [Lumberjack::LogEntryMatcher] The matcher for the expected values.
183
+ def log_entry_matcher
184
+ Lumberjack::LogEntryMatcher.new(
185
+ message: @expected_hash[:message],
186
+ severity: @expected_hash[:severity],
187
+ progname: @expected_hash[:progname],
188
+ attributes: @expected_hash[:attributes],
189
+ formatter: device&.entry_formatter
190
+ )
191
+ end
192
+
193
+ # Compare a log entry to the expected values one field at a time. The comparison itself is
194
+ # done by Lumberjack::LogEntryMatcher#diff which reports only the mismatched fields; the
195
+ # rest of the entry is filled in from the entry so it can be seen in full.
196
+ #
197
+ # @param entry [Lumberjack::LogEntry] The log entry to compare to the expectation.
198
+ # @return [Array<Array>] An array of [name, expected value, actual value, matched] tuples
199
+ # in the order they should be displayed. The values are already formatted for display.
200
+ def entry_differences(entry)
201
+ diff = log_entry_matcher.diff(entry)
202
+ differences = []
203
+
204
+ mismatch = diff["severity"]
205
+ differences << if mismatch
206
+ # Severities are already reported as labels by the diff.
207
+ ["severity", mismatch[:expected].to_s, mismatch[:actual].to_s, false]
208
+ else
209
+ ["severity", nil, entry.severity_label, true]
210
+ end
211
+
212
+ differences << field_difference("message", diff["message"], entry.message)
213
+
214
+ unless diff["progname"].nil? && entry.progname.nil?
215
+ differences << field_difference("progname", diff["progname"], entry.progname)
216
+ end
217
+
218
+ differences.concat(attribute_differences(entry, diff["attributes"]))
219
+ end
220
+
221
+ # Build the display tuple for a single log entry field.
222
+ #
223
+ # @param name [String] The name of the field.
224
+ # @param mismatch [Hash, nil] The expected and actual values reported by the diff, or nil
225
+ # if the field matched the expectation.
226
+ # @param value [Object] The value from the log entry, used when the field matched.
227
+ # @return [Array] A [name, expected value, actual value, matched] tuple.
228
+ def field_difference(name, mismatch, value)
229
+ if mismatch
230
+ [name, formatted_value(mismatch[:expected]), formatted_value(mismatch[:actual]), false]
231
+ else
232
+ [name, nil, formatted_value(value), true]
233
+ end
234
+ end
235
+
236
+ # Build the display tuples for the attributes of a log entry. Mismatches are reported by
237
+ # the diff per attribute using dot notation names. The remaining attributes on the entry
238
+ # are listed as well, followed by any expected attributes the entry does not have.
239
+ #
240
+ # @param entry [Lumberjack::LogEntry] The log entry being compared to the expectation.
241
+ # @param mismatches [Hash, nil] The attribute mismatches reported by the diff.
242
+ # @return [Array<Array>] An array of [name, expected value, actual value, matched] tuples.
243
+ def attribute_differences(entry, mismatches)
244
+ mismatches ||= {}
245
+
246
+ if mismatches.include?(:expected)
247
+ # Matchers like RSpec's hash_including are matched against the attributes hash as a whole.
248
+ return [field_difference("attributes", mismatches, nil)]
249
+ end
250
+
251
+ entry_attributes = Lumberjack::Utils.flatten_attributes(entry.attributes || {})
252
+
253
+ differences = entry_attributes.collect do |name, value|
254
+ field_difference("attributes.#{name}", mismatches[name], value)
255
+ end
256
+
257
+ (mismatches.keys - entry_attributes.keys).each do |name|
258
+ mismatch = mismatches[name]
259
+ actual = mismatch[:actual].nil? ? MISSING_VALUE : formatted_value(mismatch[:actual])
260
+ differences << ["attributes.#{name}", formatted_value(mismatch[:expected]), actual, false]
261
+ end
262
+
263
+ differences
264
+ end
265
+
266
+ # The Lumberjack::Device::Test the entries are being matched against.
267
+ #
268
+ # @return [Lumberjack::Device::Test, nil] The device, or nil if the logger is not valid.
269
+ def device
270
+ return nil unless valid_logger?
271
+
272
+ @logger.is_a?(Lumberjack::Device::Test) ? @logger : @logger.device
273
+ end
274
+
275
+ # Format a value for display in a description. Matcher objects (i.e. RSpec matchers)
276
+ # that implement a +description+ method are displayed using that description since
277
+ # inspecting them is not very informative.
278
+ #
279
+ # @param value [Object] The value to format.
280
+ # @return [String] The formatted value.
281
+ def formatted_value(value)
282
+ if value.respond_to?(:description) && !value.is_a?(Module)
283
+ value.description.to_s
284
+ else
285
+ value.inspect
286
+ end
287
+ end
153
288
  end
@@ -9,15 +9,13 @@ module Lumberjack::CaptureDevice::RSpec
9
9
  # This matcher provides better error messages than using the include? method directly.
10
10
  #
11
11
  # @param expected_hash [Hash] The expected log entry attributes to match.
12
- # @option expected_hash [String, Symbol, Integer] :level The expected log level.
13
- # @option expected_hash [String, Symbol, Integer] :severity Alias for :level.
12
+ # @option expected_hash [String, Symbol, Integer] :severity The expected log severity.
14
13
  # @option expected_hash [String, Regexp] :message The expected message content.
15
14
  # @option expected_hash [Hash] :attributes Expected log entry attributes.
16
- # @option expected_hash [Hash] :tags Alias for :attributes.
17
15
  # @option expected_hash [String] :progname Expected program name.
18
16
  # @return [Lumberjack::CaptureDevice::IncludeLogEntryMatcher] A matcher for the expected log entry.
19
17
  # @example
20
- # expect(logs).to include_log_entry(level: :info, message: "User logged in")
18
+ # expect(logs).to include_log_entry(severity: :info, message: "User logged in")
21
19
  # @example
22
20
  # expect(logs).to include_log_entry(message: /error/i, attributes: {user_id: 123})
23
21
  def include_log_entry(expected_hash)
@@ -25,8 +23,9 @@ module Lumberjack::CaptureDevice::RSpec
25
23
  end
26
24
 
27
25
  # Capture log entries from a logger within a block. This method temporarily
28
- # replaces the logger's device with a CaptureDevice, sets the log level to debug,
29
- # and removes formatters to capture raw log entries for testing.
26
+ # replaces the logger's device with a CaptureDevice and sets the log level to debug.
27
+ # The logger's formatters remain active, so captured entries contain the same
28
+ # formatted values that would have been logged.
30
29
  #
31
30
  # @param logger [Lumberjack::Logger] The logger to capture entries from.
32
31
  # @yield [device] The block to execute while capturing log entries.
@@ -36,7 +35,7 @@ module Lumberjack::CaptureDevice::RSpec
36
35
  # logs = capture_logger(Rails.logger) do
37
36
  # Rails.logger.info("Test message")
38
37
  # end
39
- # expect(logs).to include_log_entry(level: :info, message: "Test message")
38
+ # expect(logs).to include_log_entry(severity: :info, message: "Test message")
40
39
  def capture_logger(logger, write_to_original: true, &block)
41
40
  Lumberjack::CaptureDevice.capture(logger, write_to_original: write_to_original, &block)
42
41
  end
@@ -62,9 +61,8 @@ module Lumberjack::CaptureDevice::RSpec
62
61
  example.run
63
62
 
64
63
  if example.exception
65
- logger.tag(rspec: {source_location: example.source_location, description: example.metadata[:description]}) do
66
- captured_device.write_to_underlying_device
67
- end
64
+ rspec_attributes = {rspec: {location: example.location, description: example.metadata[:description]}}
65
+ captured_device.write_to_underlying_device(attributes: rspec_attributes)
68
66
  end
69
67
  end
70
68
  end
@@ -15,9 +15,16 @@ module Lumberjack
15
15
  class << self
16
16
  # Capture the entries written by the logger within a block. Within the block all log
17
17
  # entries will be written to a CaptureDevice rather than to the normal output for
18
- # the logger. In addition, all formatters will be removed and the log level will be set
19
- # to debug. The device being written to be both yielded to the block as well as returned
20
- # by the method call.
18
+ # the logger. In addition, the log level will be set to debug. The logger's formatters
19
+ # remain active, so captured entries contain the same formatted values that would have
20
+ # been logged. The device being written to be both yielded to the block as well as
21
+ # returned by the method call.
22
+ #
23
+ # This method is not thread safe. It swaps the device and log level on the logger
24
+ # itself, so concurrent calls on the same logger will interfere with each other and
25
+ # entries logged by other threads during the block will be captured as well. The log
26
+ # level is set on the current context, so threads spawned within the block may not
27
+ # log at the debug level.
21
28
  #
22
29
  # @param logger [Lumberjack::Logger] The logger to capture entries from.
23
30
  # @param write_to_original [Boolean] If true (the default) the captured entries will be written
@@ -64,11 +71,20 @@ module Lumberjack
64
71
  # @param options [Hash] Options to pass to the parent Test device.
65
72
  def initialize(options = {})
66
73
  @underlying_device = options[:underlying_device]
67
- super(options.merge(max_entries: 1_000_000))
74
+ super({max_entries: 1_000_000}.merge(options))
68
75
  end
69
76
 
70
- # Return all the captured entries that match the specified filters. These filters are
71
- # the same as described in the `include?` method.
77
+ # Return all the captured entries that match the specified filters. The device
78
+ # `entry_formatter` is used to match the filters, so unformatted values can be used in
79
+ # the filters if it is set.
80
+ #
81
+ # For severity, you can specify either a numeric constant (i.e. `Logger::WARN`) or a symbol
82
+ # (i.e. `:warn`).
83
+ #
84
+ # For message and progname you can specify a string to perform an exact match or a regular
85
+ # expression to perform a partial or pattern match. You can also supply any matcher value
86
+ # available in your test library (i.e. in rspec you could use `anything` or `instance_of(Error)`,
87
+ # etc.).
72
88
  #
73
89
  # @param message [String, Regexp, nil] The message to match against the log entries.
74
90
  # @param severity [String, Symbol, Integer, nil] The severity to match against the log entries.
@@ -78,21 +94,19 @@ module Lumberjack
78
94
  # @param progname [String, nil] The program name to match against the log entries.
79
95
  # @param limit [Integer, nil] The maximum number of entries to return. If nil, all matching entries
80
96
  # will be returned.
81
- # @param level [String, Symbol, Integer, nil] Alias for the `severity` parameter.
82
- # @param tags [Hash, nil] Alias for the `attributes` parameter.
83
97
  # @return [Array<Lumberjack::LogEntry>] An array of log entries that match the specified filters.
84
- def extract(message: nil, severity: nil, attributes: nil, progname: nil, limit: nil, level: nil, tags: nil)
98
+ # @example
99
+ # logs.extract(severity: :warn, message: /something happened/, attributes: {user: "john"})
100
+ def extract(message: nil, severity: nil, attributes: nil, progname: nil, limit: nil)
85
101
  matched = []
86
- if severity.nil? && !level.nil?
87
- Lumberjack::Utils.deprecated("Lumberjack::CaptureDevice#extract(level)", "Lumberjack::CaptureDevice#extract level parameter has been renamed to severity; it will be removed in version 2.1.")
88
- severity = level
89
- end
90
- if attributes.nil? && !tags.nil?
91
- Lumberjack::Utils.deprecated("Lumberjack::CaptureDevice#extract(tags)", "Lumberjack::CaptureDevice#extract tags parameter has been renamed to attributes; it will be removed in version 2.1.")
92
- attributes = tags
93
- end
94
102
 
95
- matcher = LogEntryMatcher.new(message: message, severity: severity, attributes: attributes, progname: progname)
103
+ matcher = LogEntryMatcher.new(
104
+ message: message,
105
+ severity: severity,
106
+ attributes: attributes,
107
+ progname: progname,
108
+ formatter: entry_formatter
109
+ )
96
110
 
97
111
  entries.each do |entry|
98
112
  matched << entry if matcher.match?(entry)
@@ -102,108 +116,53 @@ module Lumberjack
102
116
  matched
103
117
  end
104
118
 
105
- # Return true if the captured log entries match the specified level, message, and attributes.
106
- #
107
- # For level, you can specify either a numeric constant (i.e. `Logger::WARN`) or a symbol
108
- # (i.e. `:warn`).
119
+ # Return true if the captured log entries match the specified filters. The filters are the
120
+ # same as the ones used by the `extract` method.
109
121
  #
110
- # For message you can specify a string to perform an exact match or a regular expression
111
- # to perform a partial or pattern match. You can also supply any matcher value available
112
- # in your test library (i.e. in rspec you could use `anything` or `instance_of(Error)`, etc.).
113
- #
114
- # For attributes, you can specify a hash of attribute names to values to match. You can use
115
- # regular expression or matchers as the values here as well. attributes can also be nested to match
116
- # nested attributes.
122
+ # This must be redefined here because Enumerable#include? would otherwise shadow the
123
+ # implementation inherited from Lumberjack::Device::Test.
117
124
  #
118
125
  # @example
119
- # logs.include?(level: :warn, message: /something happened/, attributes: {user: "john"})
126
+ # logs.include?(severity: :warn, message: /something happened/, attributes: {user: "john"})
120
127
  #
121
128
  # @param filters [Hash] The filters to apply to the captured entries.
122
129
  # @option filters [String, Regexp] :message The message to match against the log entries.
123
- # @option filters [String, Symbol, Integer] :severity The log level to match against the log entries.
130
+ # @option filters [String, Symbol, Integer] :severity The severity to match against the log entries.
124
131
  # @option filters [Hash] :attributes A hash of attribute names to values to match against the log entries. The attributes
125
132
  # will match nested attributes using dot notation (e.g. `foo.bar` will match an attribute with the structure
126
133
  # +{foo: {bar: "value"}}+).
127
134
  # @option filters [String] :progname The program name to match against the log entries.
128
- # @option filters [String, Symbol, Integer, nil] :level Alias for the `severity` option. This option is deprecated.
129
- # @option filters [Hash, nil] :tags Alias for the `attributes` option. This option is deprecated.
130
135
  # @return [Boolean] True if any entries match the specified filters, false otherwise.
131
136
  def include?(filters)
132
- if filters.include?(:level)
133
- Lumberjack::Utils.deprecated("Lumberjack::CaptureDevice#include?(level)", "Lumberjack::CaptureDevice#include? level option has been renamed to severity; it will be removed in version 2.1.")
134
- end
135
-
136
- if filters.include?(:tags)
137
- Lumberjack::Utils.deprecated("Lumberjack::CaptureDevice#include?(tags)", "Lumberjack::CaptureDevice#include? tags option has been renamed to attributes; it will be removed in version 2.1.")
137
+ filters = filters.transform_keys(&:to_sym)
138
+ unknown_keys = filters.keys - [:message, :severity, :attributes, :progname]
139
+ unless unknown_keys.empty?
140
+ raise ArgumentError, "unknown log filters: #{unknown_keys.map(&:inspect).join(", ")}"
138
141
  end
139
142
 
140
- munged_filters = {
141
- message: filters[:message],
142
- severity: filters[:severity] || filters[:level],
143
- attributes: filters[:attributes] || filters[:tags],
144
- progname: filters[:progname]
145
- }.compact
146
-
147
- !!match(**munged_filters)
148
- end
149
-
150
- # Return the first captured entry that matches the filters.
151
- #
152
- # @param message [String, Regexp, nil] The message to match against the log entries.
153
- # @param severity [String, Symbol, Integer, nil] The log level to match against the log entries.
154
- # @param attributes [Hash, nil] A hash of attribute names to values to match against the log entries. The attributes
155
- # will match nested attributes using dot notation (e.g. `foo.bar` will match an attribute with the structure
156
- # +{foo: {bar: "value"}}+).
157
- # @param progname [String, nil] The program name to match against the log entries.
158
- # @param level [String, Symbol, Integer, nil] Alias for the `severity` parameter. This parameter is deprecated.
159
- # @param tags [Hash, nil] Alias for the `attributes` parameter. This parameter is deprecated.
160
- # @return [Lumberjack::LogEntry, nil] The first matching log entry, or nil if no match is found.
161
- def match(message: nil, severity: nil, attributes: nil, progname: nil, level: nil, tags: nil)
162
- unless level.nil?
163
- Lumberjack::Utils.deprecated("Lumberjack::CaptureDevice#match(level)", "Lumberjack::CaptureDevice#match level parameter has been renamed to severity; it will be removed in version 2.1.")
164
- end
165
- unless tags.nil?
166
- Lumberjack::Utils.deprecated("Lumberjack::CaptureDevice#match(tags)", "Lumberjack::CaptureDevice#match tags parameter has been renamed to attributes; it will be removed in version 2.1.")
167
- end
168
-
169
- super(message: message, severity: severity || level, attributes: attributes || tags, progname: progname)
170
- end
171
-
172
- # Return the log entry that most closely matches the specified filters. This method
173
- # uses fuzzy matching logic to find the best match when no exact match exists.
174
- # The matching score is calculated based on how many criteria are met and how closely
175
- # they match. Returns nil if no entry meets the minimum matching criteria.
176
- #
177
- # @param message [String, Regexp, nil] The message to match against the log entries.
178
- # @param severity [String, Symbol, Integer, nil] The severity to match against the log entries.
179
- # @param attributes [Hash, nil] A hash of attribute names to values to match against the log entries.
180
- # @param progname [String, nil] The program name to match against the log entries.
181
- # @param level [String, Symbol, Integer, nil] Alias for the `severity` parameter.
182
- # @param tags [Hash, nil] Alias for the `attributes` parameter.
183
- # @return [Lumberjack::LogEntry, nil] The log entry that most closely matches the filters, or nil if no entry meets minimum criteria.
184
- def closest_match(message: nil, severity: nil, attributes: nil, progname: nil, level: nil, tags: nil)
185
- return nil if length == 0
186
-
187
- unless level.nil?
188
- Lumberjack::Utils.deprecated("Lumberjack::CaptureDevice#closest_match(level)", "Lumberjack::CaptureDevice#closest_match level parameter has been renamed to severity; it will be removed in version 2.1.")
189
- end
190
- unless tags.nil?
191
- Lumberjack::Utils.deprecated("Lumberjack::CaptureDevice#closest_match(tags)", "Lumberjack::CaptureDevice#closest_match tags parameter has been renamed to attributes; it will be removed in version 2.1.")
192
- end
193
-
194
- super(
195
- message: message,
196
- severity: severity || level,
197
- attributes: attributes || tags,
198
- progname: progname
199
- )
143
+ !!match(**filters)
200
144
  end
201
145
 
202
146
  # Write the captured log entries to the underlying device.
203
147
  #
148
+ # @param attributes [Hash, nil] Additional attributes to add to each entry as it is
149
+ # written. Attributes already set on an entry take precedence over these values.
204
150
  # @return [void]
205
- def write_to_underlying_device
206
- write_to(@underlying_device) if @underlying_device
151
+ def write_to_underlying_device(attributes: nil)
152
+ return unless @underlying_device
153
+
154
+ if attributes.nil? || attributes.empty?
155
+ write_to(@underlying_device)
156
+ else
157
+ extra_attributes = Lumberjack::Utils.expand_attributes(attributes)
158
+ entries.each do |entry|
159
+ copy = entry.dup
160
+ copy.attributes = extra_attributes.merge(Lumberjack::Utils.expand_attributes(entry.attributes || {}))
161
+ @underlying_device.write(copy)
162
+ end
163
+ end
164
+
165
+ nil
207
166
  end
208
167
 
209
168
  # Provide a detailed string representation showing all captured entries.
@@ -228,11 +187,20 @@ module Lumberjack
228
187
  "<##{self.class.name} #{length} #{(length == 1) ? "entry" : "entries"} captured>"
229
188
  end
230
189
 
190
+ # Return a thread-safe copy of all captured log entries. This must be redefined here
191
+ # because Enumerable#entries would otherwise shadow the thread-safe implementation
192
+ # inherited from Lumberjack::Device::Test.
193
+ #
194
+ # @return [Array<Lumberjack::LogEntry>] A copy of all captured log entries.
195
+ def entries
196
+ @lock.synchronize { @buffer.dup }
197
+ end
198
+
231
199
  # Return the number of captured log entries.
232
200
  #
233
201
  # @return [Integer] The number of captured entries.
234
202
  def length
235
- @buffer.length
203
+ entries.length
236
204
  end
237
205
 
238
206
  alias_method :size, :length
@@ -243,7 +211,7 @@ module Lumberjack
243
211
  # @yieldparam entry [Lumberjack::LogEntry] A captured log entry.
244
212
  # @return [Array<Lumberjack::LogEntry>] The captured entries (when no block given).
245
213
  def each(&block)
246
- @buffer.each(&block)
214
+ entries.each(&block)
247
215
  end
248
216
  end
249
217
  end
@@ -33,6 +33,5 @@ Gem::Specification.new do |spec|
33
33
 
34
34
  spec.required_ruby_version = ">= 2.7"
35
35
 
36
- spec.add_dependency "lumberjack", ">=2.0"
37
- spec.add_development_dependency "bundler"
36
+ spec.add_dependency "lumberjack", ">= 2.1.0"
38
37
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lumberjack_capture_device
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -15,28 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '2.0'
18
+ version: 2.1.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '2.0'
26
- - !ruby/object:Gem::Dependency
27
- name: bundler
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '0'
33
- type: :development
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '0'
25
+ version: 2.1.0
40
26
  email:
41
27
  - bbdurand@gmail.com
42
28
  executables: []
@@ -48,7 +34,6 @@ files:
48
34
  - README.md
49
35
  - VERSION
50
36
  - lib/lumberjack/capture_device.rb
51
- - lib/lumberjack/capture_device/entry_score.rb
52
37
  - lib/lumberjack/capture_device/include_log_entry_matcher.rb
53
38
  - lib/lumberjack/capture_device/rspec.rb
54
39
  - lib/lumberjack_capture_device.rb
@@ -74,7 +59,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
74
59
  - !ruby/object:Gem::Version
75
60
  version: '0'
76
61
  requirements: []
77
- rubygems_version: 3.6.9
62
+ rubygems_version: 4.0.3
78
63
  specification_version: 4
79
64
  summary: Testing device for the lumberjack gem that can be used for asserting messages
80
65
  have been logged in a test suite.
@@ -1,276 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Class responsible for scoring and matching log entries against filters.
4
- # This class provides fuzzy matching capabilities to find the best matching
5
- # log entry when exact matches are not available.
6
- class Lumberjack::CaptureDevice::EntryScore
7
- # Minimum score threshold for considering a match (30% match)
8
- MIN_SCORE_THRESHOLD = 0.3
9
-
10
- class << self
11
- # Calculate the overall match score for an entry against all provided filters.
12
- # Returns a score between 0.0 and 1.0, where 1.0 represents a perfect match.
13
- #
14
- # @param entry [Lumberjack::LogEntry] The log entry to score.
15
- # @param message_filter [String, Regexp, nil] The message filter to match against.
16
- # @param severity_filter [Integer, nil] The severity level to match against.
17
- # @param attributes_filter [Hash, nil] The attributes hash to match against.
18
- # @param progname_filter [String, nil] The program name to match against.
19
- # @return [Float] A score between 0.0 and 1.0 indicating match quality.
20
- def calculate_match_score(entry, message_filter, severity_filter, attributes_filter, progname_filter)
21
- scores = []
22
- weights = []
23
-
24
- # Check message match
25
- if message_filter
26
- message_score = calculate_field_score(entry.message, message_filter)
27
- scores << message_score
28
- weights << 0.4 # Weight message matching highly
29
- end
30
-
31
- # Check severity match
32
- if severity_filter
33
- severity_score = if entry.severity == severity_filter
34
- 1.0 # Exact severity match
35
- else
36
- severity_proximity_score(entry.severity, severity_filter) # Partial severity match
37
- end
38
- scores << severity_score
39
- weights << 0.3
40
- end
41
-
42
- # Check progname match
43
- if progname_filter
44
- progname_score = calculate_field_score(entry.progname, progname_filter)
45
- scores << progname_score
46
- weights << 0.2
47
- end
48
-
49
- # Check attributes match
50
- if attributes_filter.is_a?(Hash) && !attributes_filter.empty?
51
- attributes_score = calculate_attributes_score(entry.attributes, attributes_filter)
52
- scores << attributes_score
53
- weights << 0.3
54
- end
55
-
56
- # Return 0 if no criteria were provided
57
- return 0.0 if scores.empty?
58
-
59
- # Calculate weighted average, but apply a penalty if any score is 0
60
- # This ensures that completely failed criteria significantly impact the result
61
- total_weighted_score = scores.zip(weights).map { |score, weight| score * weight }.sum
62
- total_weight = weights.sum
63
- base_score = total_weighted_score / total_weight
64
-
65
- # Apply penalty for zero scores: reduce the score based on how many criteria completely failed
66
- zero_scores = scores.count(0.0)
67
- if zero_scores > 0
68
- penalty_factor = 1.0 - (zero_scores.to_f / scores.length * 0.5) # Up to 50% penalty
69
- base_score *= penalty_factor
70
- end
71
-
72
- base_score
73
- end
74
-
75
- # Calculate score for any field value against a filter.
76
- # Returns a score between 0.0 and 1.0 based on how well the value matches the filter.
77
- #
78
- # @param value [Object] The value to match against the filter.
79
- # @param filter [String, Regexp, Object] The filter to match the value against.
80
- # @return [Float] A score between 0.0 and 1.0 indicating match quality.
81
- def calculate_field_score(value, filter)
82
- return 0.0 unless value && filter
83
-
84
- case filter
85
- when String
86
- value_str = value.to_s
87
- if value_str == filter
88
- 1.0
89
- elsif value_str.include?(filter)
90
- 0.7
91
- else
92
- # Use string similarity for partial matching
93
- similarity = string_similarity(value_str, filter)
94
- (similarity > 0.5) ? similarity * 0.6 : 0.0
95
- end
96
- when Regexp
97
- filter.match?(value.to_s) ? 1.0 : 0.0
98
- else
99
- # For other matchers (like RSpec matchers), try to use === operator
100
- begin
101
- (filter === value) ? 1.0 : 0.0
102
- rescue
103
- 0.0
104
- end
105
- end
106
- end
107
-
108
- # Calculate proximity score based on log severity distance.
109
- # Provides partial scoring for severities that are close to the target.
110
- #
111
- # @param entry_severity [Integer] The severity level of the log entry.
112
- # @param filter_severity [Integer] The target severity level to match.
113
- # @return [Float] A score between 0.0 and 1.0 based on severity proximity.
114
- def severity_proximity_score(entry_severity, filter_severity)
115
- severity_diff = (entry_severity - filter_severity).abs
116
- case severity_diff
117
- when 0 then 1.0
118
- when 1 then 0.7
119
- when 2 then 0.4
120
- else 0.0
121
- end
122
- end
123
-
124
- # Calculate score for attribute matching.
125
- # Compares entry attributes against filter attributes and returns a score
126
- # based on how many attributes match.
127
- #
128
- # @param entry_attributes [Hash] The attributes from the log entry.
129
- # @param attributes_filter [Hash] The attributes filter to match against.
130
- # @return [Float] A score between 0.0 and 1.0 based on attribute matches.
131
- def calculate_attributes_score(entry_attributes, attributes_filter)
132
- return 0.0 unless entry_attributes && attributes_filter.is_a?(Hash)
133
-
134
- attributes_filter = deep_stringify_keys(Lumberjack::Utils.expand_attributes(attributes_filter))
135
- attributes = deep_stringify_keys(Lumberjack::Utils.expand_attributes(entry_attributes))
136
-
137
- total_attribute_filters = count_attribute_filters(attributes_filter)
138
- return 0.0 if total_attribute_filters == 0
139
-
140
- matched_attributes = count_matched_attributes(attributes, attributes_filter)
141
- matched_attributes.to_f / total_attribute_filters
142
- end
143
-
144
- private
145
-
146
- # Calculate string similarity using a simple Levenshtein distance-based approach.
147
- # Returns a score between 0.0 and 1.0 where 1.0 is an exact match.
148
- #
149
- # @param str1 [String] The first string to compare.
150
- # @param str2 [String] The second string to compare.
151
- # @return [Float] A similarity score between 0.0 and 1.0.
152
- def string_similarity(str1, str2)
153
- return 1.0 if str1 == str2
154
- return 0.0 if str1.nil? || str2.nil? || str1.empty? || str2.empty?
155
-
156
- # Convert to lowercase for case-insensitive comparison
157
- s1 = str1.downcase
158
- s2 = str2.downcase
159
-
160
- # If one string contains the other, give it a good score
161
- if s1.include?(s2) || s2.include?(s1)
162
- shorter = [s1.length, s2.length].min
163
- longer = [s1.length, s2.length].max
164
- return shorter.to_f / longer * 0.8 + 0.2 # Boost score for containment
165
- end
166
-
167
- # Calculate Levenshtein distance
168
- distance = levenshtein_distance(s1, s2)
169
- max_length = [s1.length, s2.length].max
170
-
171
- # Convert distance to similarity score
172
- return 0.0 if max_length == 0
173
-
174
- 1.0 - (distance.to_f / max_length)
175
- end
176
-
177
- # Simple Levenshtein distance implementation.
178
- # Calculates the minimum number of single-character edits needed
179
- # to change one string into another.
180
- #
181
- # @param str1 [String] The first string.
182
- # @param str2 [String] The second string.
183
- # @return [Integer] The Levenshtein distance between the strings.
184
- def levenshtein_distance(str1, str2)
185
- return str2.length if str1.empty?
186
- return str1.length if str2.empty?
187
-
188
- matrix = Array.new(str1.length + 1) { Array.new(str2.length + 1, 0) }
189
-
190
- # Initialize first row and column
191
- (0..str1.length).each { |i| matrix[i][0] = i }
192
- (0..str2.length).each { |j| matrix[0][j] = j }
193
-
194
- # Fill the matrix
195
- (1..str1.length).each do |i|
196
- (1..str2.length).each do |j|
197
- cost = (str1[i - 1] == str2[j - 1]) ? 0 : 1
198
- matrix[i][j] = [
199
- matrix[i - 1][j] + 1, # deletion
200
- matrix[i][j - 1] + 1, # insertion
201
- matrix[i - 1][j - 1] + cost # substitution
202
- ].min
203
- end
204
- end
205
-
206
- matrix[str1.length][str2.length]
207
- end
208
-
209
- # Count the total number of attribute filters in a nested hash structure.
210
- #
211
- # @param attributes_filter [Hash] The attributes filter hash to count.
212
- # @param count [Integer] The current count (used for recursion).
213
- # @return [Integer] The total number of filters.
214
- def count_attribute_filters(attributes_filter, count = 0)
215
- attributes_filter.each do |_name, value_filter|
216
- if value_filter.is_a?(Hash)
217
- count = count_attribute_filters(value_filter, count)
218
- else
219
- count += 1
220
- end
221
- end
222
- count
223
- end
224
-
225
- # Count the number of matched attributes in a nested structure.
226
- #
227
- # @param attributes [Hash] The log entry attributes to check.
228
- # @param attributes_filter [Hash] The filter attributes to match against.
229
- # @param count [Integer] The current count (used for recursion).
230
- # @return [Integer] The number of matched attributes.
231
- def count_matched_attributes(attributes, attributes_filter, count = 0)
232
- return count unless attributes && attributes_filter
233
-
234
- attributes_filter.each do |name, value_filter|
235
- name = name.to_s
236
- attribute_values = attributes[name]
237
-
238
- if value_filter.is_a?(Hash) && attribute_values.is_a?(Hash)
239
- count = count_matched_attributes(attribute_values, value_filter, count)
240
- elsif attributes.include?(name) && exact_match?(attribute_values, value_filter)
241
- count += 1
242
- end
243
- end
244
- count
245
- end
246
-
247
- # Check if a value exactly matches the filter using the === operator.
248
- #
249
- # @param value [Object] The value to match.
250
- # @param filter [Object] The filter to match against.
251
- # @return [Boolean] True if the value matches the filter.
252
- def exact_match?(value, filter)
253
- return true unless filter
254
-
255
- filter === value
256
- end
257
-
258
- # Recursively convert all keys in a hash structure to strings.
259
- #
260
- # @param hash [Hash, Object] The hash to stringify or other object to return as-is.
261
- # @return [Hash, Object] The hash with string keys or the original object.
262
- def deep_stringify_keys(hash)
263
- if hash.is_a?(Hash)
264
- hash.each_with_object({}) do |(key, value), result|
265
- new_key = key.to_s
266
- new_value = deep_stringify_keys(value)
267
- result[new_key] = new_value
268
- end
269
- elsif hash.is_a?(Enumerable)
270
- hash.collect { |item| deep_stringify_keys(item) }
271
- else
272
- hash
273
- end
274
- end
275
- end
276
- end