ar-query-matchers 0.7.0 → 0.9.1

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: e73f1af783ac0aea8a1342b20e01d139106e29c2c4048bf3d30086a13675f204
4
- data.tar.gz: 64b309608ae8f52ba8d7a998ea0e7658eebb2ee7d11122d8e1c0934f042131b6
3
+ metadata.gz: 85641d6041fd4ef3611a70e0f3b5481e9e4d80d23b1ee0a229e5006831367464
4
+ data.tar.gz: c27f7061b4b22ff584c90a5d4122f2c71b4e5a452f59c432f215fdbf45b01be1
5
5
  SHA512:
6
- metadata.gz: 13b5fe44e89c7e0df944882d4d15dac2ff72b8fe3a679591c46b15015587159246c4e2344d2e604b6a31a0ee6dc5a450eacd85ef638d90d578f1b4073fca2f58
7
- data.tar.gz: 61d48528375466023a71bf17ad2e453ec62ce5520f43200f88de94ff2f116522be3694fe2c0116f83093fa5f1087cf4774471143e63deb8f465dd03173bda8cd
6
+ metadata.gz: 0aa94f77507d3bd00e0ca8b982a665df41ececebc657298241271c2e9d7e15a42e84a387b843537f63711e520b56c5b6c37c1b365e9e73930903269716b7158d
7
+ data.tar.gz: 3605475400e443fb20782d5f651aed89d359ff244c2d8df82d9b02989d957162aa366d45a3cf44ae9cce6afffc29ee46c10a9e0e9024958bbdf150e2c2c56a78
data/CHANGELOG.md CHANGED
@@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.0] - 2022-01-27
10
+ ### Added
11
+ - Changed the implementation of ArQueryMatchers::Queries::TableName to calculate distance of class name to table name to determine which class name is most likely. This changes how tables in `only_load_at_most_models` are calculated.
12
+
9
13
  ## [0.7.0] - 2022-01-27
10
14
  ### Added
11
15
  - A new matcher, `only_load_at_most_models`, will do a less-than-or-equal-to (<=) check on model counts. This is a method that makes tests less noisy as performance gets better.
data/README.md CHANGED
@@ -16,7 +16,7 @@ If you'd like to pick that up, please have a look at: https://github.com/Gusto/a
16
16
  Include it in your Gemfile:
17
17
  ```ruby
18
18
  group :test do
19
- gem 'ar-query-matchers', '~> 0.2.0', require: false
19
+ gem 'ar-query-matchers', '~> 0.7.0', require: false
20
20
  end
21
21
  ```
22
22
 
@@ -40,9 +40,10 @@ This gem defines a few categories of matchers:
40
40
  - **Update**: Which models are updated during a block
41
41
 
42
42
  Each matcher category includes 3 assertions, for example, for the Load category, you could use the following assertions:
43
- - **only_load_models**: Strict assertion, not other query is allowed.
44
- - **not_load_models**: No models are allowed to be loaded.
45
- - **load_models**: Inclusion, other models are allowed to be loaded if not specified in the assertion.
43
+ - **only_load_models**: Strict assertion of both models loaded and query counts. No other query is allowed.
44
+ - **only_load_at_most_models**: Strict assertion of models loaded, with an upper bound on the number of queries allowed against each.
45
+ - **not_load_any_models**: No models are allowed to be loaded.
46
+ - **load_models**: Inclusion. Other models are allowed to be loaded if not specified in the assertion.
46
47
 
47
48
 
48
49
  **For example:**
@@ -58,9 +59,20 @@ expect { some_code() }.to only_load_models(
58
59
  )
59
60
  ```
60
61
 
62
+ The following spec will pass only if there are 4 or less SQL SELECTs that
63
+ load User records (and 1 or less for both Address and Payroll respectively) _and_ no other models
64
+ perform any SELECT queries.
65
+ ```ruby
66
+ expect { some_code() }.to only_load_at_most_models(
67
+ 'User' => 4,
68
+ 'Address' => 1,
69
+ 'Payroll' => 1,
70
+ )
71
+ ```
72
+
61
73
  The following spec will pass only if there are no select queries.
62
74
  ```ruby
63
- expect { some_code() }.to not_load_models
75
+ expect { some_code() }.to not_load_any_models
64
76
  ```
65
77
 
66
78
  The following spec will pass only if there are exactly 4 SQL SELECTs that
@@ -17,7 +17,7 @@ module ArQueryMatchers
17
17
  class CreateQueryFilter < QueryFilter
18
18
  # Matches unnamed SQL operations like the following:
19
19
  # "INSERT INTO `company_approval_details` ..."
20
- TABLE_NAME_SQL_PATTERN = /INSERT INTO [`"](?<table_name>[^`"]+)[`"]/.freeze
20
+ TABLE_NAME_SQL_PATTERN = /INSERT INTO [`"](?<table_name>[^`"]+)[`"]/
21
21
 
22
22
  def filter_map(_name, sql)
23
23
  # for inserts, name is always 'SQL', we have to rely on pattern matching the query string.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './query_counter'
4
+ require_relative './field_name'
5
+ require_relative './query_filter'
6
+
7
+ module ArQueryMatchers
8
+ module Queries
9
+ # A specialized QueryCounter for "any action that involves field IDs".
10
+ # For more information, see the QueryCounter class.
11
+ class FieldCounter
12
+ def self.instrument(&block)
13
+ QueryCounter.new(FieldCounterFilter.new).instrument(&block)
14
+ end
15
+
16
+ # Filters queries for counting purposes
17
+ class FieldCounterFilter < Queries::QueryFilter
18
+ # We need to look for a few things:
19
+ # Anything with ` {field} = {value}` (this could be a select, update, delete)
20
+ MODEL_FIELDS_PATTERN = /\.`(?<field_name>\w+)` = (?<field_value>[\w"`]+)/
21
+
22
+ # Anything with ` {field} IN ({value})` (this could be a select, update, delete)
23
+ MODEL_FIELDS_IN_PATTERN = /\.`(?<field_name>\w+)` IN \((?<field_value>[\w"`]+)\)/
24
+
25
+ # Anything with `, field,` in an INSERT (we need to check the values)
26
+ MODEL_INSERT_PATTERN = /INSERT INTO (?<table_name>[^`"]+) ... VALUES .../
27
+
28
+ def cleanup(value)
29
+ cleaned_value = value.gsub '`', ''
30
+
31
+ # If this is an integer, we'll cast it automatically
32
+ cleaned_value = value.to_i if cleaned_value == value
33
+
34
+ cleaned_value
35
+ end
36
+
37
+ def filter_map(_name, sql)
38
+ # We need to look for a few things:
39
+ # - Anything with ` {field} = ` (this could be a select, update, delete)
40
+ # - Anything with `, field,` in an INSERT (we need to check the values)
41
+ select_field_query = sql.match(MODEL_FIELDS_PATTERN)
42
+ # debugger if sql.match(/INSERT/)
43
+ # TODO: MODEL_FIELDS_IN_PATTERN and MODEL_INSERT_PATTERN need to be handled
44
+
45
+ FieldName.new(select_field_query[:field_name], cleanup(select_field_query[:field_value])) if select_field_query
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArQueryMatchers
4
+ module Queries
5
+ # An instance of this class is one of the values that could be returned from the QueryFilter#filter_map.
6
+ # it accepts a name of an ActiveRecord model, for example: 'Company'.
7
+ class FieldName
8
+ attr_reader(:model_name, :model_value)
9
+
10
+ def initialize(model_name, model_value)
11
+ @model_name = model_name
12
+ @model_value = model_value
13
+ end
14
+ end
15
+ end
16
+ end
@@ -17,17 +17,17 @@ module ArQueryMatchers
17
17
  class LoadQueryFilter < Queries::QueryFilter
18
18
  # Matches named SQL operations like the following:
19
19
  # 'User Load'
20
- MODEL_LOAD_PATTERN = /\A(?<model_name>[\w:]+) (Load|Exists)\Z/.freeze
20
+ MODEL_LOAD_PATTERN = /\A(?<field_name>[\w:]+)/
21
21
 
22
22
  # Matches unnamed SQL operations like the following:
23
23
  # "SELECT COUNT(*) FROM `users` ..."
24
- MODEL_SQL_PATTERN = /SELECT (?:(?!SELECT).)* FROM [`"](?<table_name>[^`"]+)[`"]/.freeze
24
+ MODEL_SQL_PATTERN = /SELECT (?:(?!SELECT).)* FROM [`"](?<table_name>[^`"]+)[`"]/
25
25
 
26
26
  def filter_map(name, sql)
27
27
  # First check for a `SELECT * FROM` query that ActiveRecord has
28
28
  # helpfully named for us in the payload
29
29
  match = name.match(MODEL_LOAD_PATTERN)
30
- return ModelName.new(match[:model_name]) if match
30
+ return ModelName.new(match[:model_name]) if match&.names&.include? :model_name
31
31
 
32
32
  # Fall back to pattern-matching on the table name in a COUNT and looking
33
33
  # up the table name from ActiveRecord's loaded descendants.
@@ -44,6 +44,14 @@ module ArQueryMatchers
44
44
  Hash[*queries.reduce({}) { |acc, (model_name, data)| acc.update model_name => data[:count] }.sort_by(&:first).flatten]
45
45
  end
46
46
 
47
+ def query_values
48
+ result = {}
49
+ queries.each do |model_name, data|
50
+ result[model_name] = data[:values]
51
+ end
52
+ result
53
+ end
54
+
47
55
  # @return [Hash] of line in the source code to its frequency
48
56
  def query_lines_by_frequency
49
57
  queries.reduce({}) do |lines, (model_name, data)|
@@ -62,7 +70,7 @@ module ArQueryMatchers
62
70
  # @param [block] block to instrument
63
71
  # @return [QueryStats] stats about all the SQL queries executed during the block
64
72
  def instrument(&block)
65
- queries = Hash.new { |h, k| h[k] = { count: 0, lines: [], time: BigDecimal(0) } }
73
+ queries = Hash.new { |h, k| h[k] = { count: 0, lines: [], values: [], time: BigDecimal(0) } }
66
74
  ActiveSupport::Notifications.subscribed(to_proc(queries), 'sql.active_record', &block)
67
75
  QueryStats.new(queries)
68
76
  end
@@ -71,9 +79,18 @@ module ArQueryMatchers
71
79
 
72
80
  # The 'marginalia' gem adds a line from the backtrace to the SQL query in
73
81
  # the form of a comment.
74
- MARGINALIA_SQL_COMMENT_PATTERN = %r{/*line:(?<line>.*)'*/}.freeze
82
+ MARGINALIA_SQL_COMMENT_PATTERN = %r{/*line:(?<line>.*)'*/}
75
83
  private_constant :MARGINALIA_SQL_COMMENT_PATTERN
76
84
 
85
+ def add_to_query(queries, payload, model_obj, finish, start)
86
+ model_name = model_obj&.model_name
87
+ comment = payload[:sql].match(MARGINALIA_SQL_COMMENT_PATTERN)
88
+ queries[model_name][:lines] << comment[:line] if comment
89
+ queries[model_name][:count] += 1
90
+ queries[model_name][:values].append(model_obj&.model_value) if model_obj.respond_to?(:model_value) && !queries[model_name][:values].include?(model_obj&.model_value)
91
+ queries[model_name][:time] += (finish - start).round(6) # Round to microseconds
92
+ end
93
+
77
94
  def to_proc(queries)
78
95
  lambda do |_name, start, finish, _message_id, payload|
79
96
  return if payload[:cached]
@@ -81,14 +98,10 @@ module ArQueryMatchers
81
98
  # Given a `sql.active_record` event, figure out which model is being
82
99
  # accessed. Some of the simpler queries have a :name key that makes this
83
100
  # really easy. Others require parsing the SQL by hand.
84
- model_name = @query_filter.filter_map(payload[:name] || '', payload[:sql] || '')&.model_name
101
+ model_obj = @query_filter.filter_map(payload[:name] || '', payload[:sql] || '')
102
+ model_name = model_obj&.model_name
85
103
 
86
- if model_name
87
- comment = payload[:sql].match(MARGINALIA_SQL_COMMENT_PATTERN)
88
- queries[model_name][:lines] << comment[:line] if comment
89
- queries[model_name][:count] += 1
90
- queries[model_name][:time] += (finish - start).round(6) # Round to microseconds
91
- end
104
+ add_to_query(queries, payload, model_obj, finish, start) if model_name
92
105
  end
93
106
  end
94
107
  end
@@ -31,9 +31,9 @@ module ArQueryMatchers
31
31
  # { 'users' => [User, AtoUser],
32
32
  # 'employees => [Employee, PandaFlows::StateFields] }
33
33
 
34
- # Of all the models that share the same table name sort them by their
35
- # relative ancestry and pick the one that all the rest inherit from
36
- tables[table_name].min_by { |a, b| a.ancestors.include?(b) }
34
+ # Pick the class with the shortest distance to the table name
35
+ ideal_class_name = table_name.classify
36
+ tables[table_name].min_by { |a| DidYouMean::Levenshtein.distance(ideal_class_name, a.to_s) }
37
37
  end
38
38
  end
39
39
  end
@@ -17,7 +17,7 @@ module ArQueryMatchers
17
17
  class UpdateQueryFilter < QueryFilter
18
18
  # Matches unnamed SQL operations like the following:
19
19
  # "UPDATE `bank_account_verifications` ..."
20
- TABLE_NAME_SQL_PATTERN = /UPDATE [`"](?<table_name>[^`"]+)[`"]/.freeze
20
+ TABLE_NAME_SQL_PATTERN = /UPDATE [`"](?<table_name>[^`"]+)[`"]/
21
21
 
22
22
  def filter_map(_name, sql)
23
23
  # for updates, name is always 'SQL', we have to rely on pattern matching on the query string instead.
@@ -3,6 +3,7 @@
3
3
  require 'ar_query_matchers/queries/create_counter'
4
4
  require 'ar_query_matchers/queries/load_counter'
5
5
  require 'ar_query_matchers/queries/update_counter'
6
+ require 'ar_query_matchers/queries/field_counter'
6
7
  require 'bigdecimal'
7
8
 
8
9
  module ArQueryMatchers
@@ -225,6 +226,86 @@ module ArQueryMatchers
225
226
  end
226
227
  end
227
228
 
229
+ class FieldModels
230
+ # The following will succeed:
231
+ #
232
+ # expect {
233
+ # WcRiskClass.last.update_attributes(id: 9999)
234
+ # WcRiskClass.last.update_attributes(id: 1234)
235
+ # }.to query_by_field(
236
+ # 'id' => [9999, 1234],
237
+ # )
238
+ #
239
+ RSpec::Matchers.define(:query_by_field) do |expected = {}|
240
+ include MatcherConfiguration
241
+ include MatcherErrors
242
+
243
+ # Convert the map of expected values to a Hash of all arrays
244
+ expected = expected.transform_values { |v| v.is_a(Array) ? v : [v] }
245
+
246
+ match do |block|
247
+ @query_stats = Queries::FieldCounter.instrument(&block)
248
+ expected == @query_stats.query_values
249
+ end
250
+
251
+ def failure_text
252
+ expectation_failed_message('query_by', show_values: true)
253
+ end
254
+ end
255
+
256
+ # The following will succeed:
257
+ #
258
+ # expect {
259
+ # WcRiskClass.last.update_attributes(id: 9999)
260
+ # WcRiskClass.last.update_attributes(id: 1234)
261
+ # }.to query_by_field_at_least(
262
+ # 'id' => 9999,
263
+ # )
264
+ #
265
+ RSpec::Matchers.define(:query_by_field_at_least) do |expected = {}|
266
+ include MatcherConfiguration
267
+ include MatcherErrors
268
+
269
+ # Convert the map of expected values to a Hash of all arrays
270
+ expected = expected.transform_values { |v| v.is_a?(Array) ? v : [v] }
271
+
272
+ match do |block|
273
+ @query_stats = Queries::FieldCounter.instrument(&block)
274
+ expected == @query_stats.query_values.select { |k, _| expected.keys.include?(k) }
275
+ end
276
+
277
+ def failure_text
278
+ expectation_failed_message('query_by', show_values: true, subset: true)
279
+ end
280
+ end
281
+
282
+ # The following will succeed:
283
+ #
284
+ # expect {
285
+ # WcRiskClass.last.update_attributes(id: 9999)
286
+ # WcRiskClass.last.update_attributes(id: 1234)
287
+ # }.to query_by_field_at_least_ignore_notfound(
288
+ # 'id' => 6666,
289
+ # )
290
+ #
291
+ RSpec::Matchers.define(:query_by_field_at_least_ignore_notfound) do |expected = {}|
292
+ include MatcherConfiguration
293
+ include MatcherErrors
294
+
295
+ # Convert the map of expected values to a Hash of all arrays
296
+ expected = expected.transform_values { |v| v.is_a?(Array) ? v : [v] }
297
+
298
+ match do |block|
299
+ @query_stats = Queries::FieldCounter.instrument(&block)
300
+ expected.select { |k, _| @query_stats.query_values.keys.include?(k) } == @query_stats.query_values.select { |k, _| expected.keys.include?(k) }
301
+ end
302
+
303
+ def failure_text
304
+ expectation_failed_message('query_by', show_values: true, subset: true, ignore_missing: true)
305
+ end
306
+ end
307
+ end
308
+
228
309
  # Shared methods that are included in the matchers.
229
310
  # They configure it and ensure we get consistent and human readable error messages
230
311
  module MatcherConfiguration
@@ -246,22 +327,37 @@ module ArQueryMatchers
246
327
  end
247
328
 
248
329
  module MatcherErrors
249
- # Show the difference between expected and actual values with one value
250
- # per line. This is done by hand because as of this writing the author
251
- # doesn't understand how RSpec does its nice hash diff printing.
252
- def difference(keys)
253
- max_key_length = keys.reduce(0) { |max, key| [max, key.size].max }
330
+ def create_display_string(max_key_length, key, left, right, show_values)
331
+ diff_array = right - left
332
+ diff_array = left - right if diff_array.empty?
333
+ "#{key.rjust(max_key_length, ' ')} – expected: #{left}, got: #{right} #{"(difference: #{diff_array})" if show_values}"
334
+ end
254
335
 
336
+ def loop_through_keys(keys, transformed_expected, show_values)
337
+ max_key_length = keys.reduce(0) { |max, key| [max, key.size].max }
255
338
  keys.map do |key|
256
- left = expected.fetch(key, 0)
257
- right = @query_stats.queries.fetch(key, {}).fetch(:count, 0)
339
+ left = transformed_expected.fetch(key, show_values ? [] : 0)
340
+ left = [left] unless left.is_a?(Array) || show_values
258
341
 
259
- diff = "#{'+' if right > left}#{right - left}"
342
+ right = @query_stats.queries.fetch(key, {})
343
+ right = show_values ? right.fetch(:values, []) : right.fetch(:count, 0)
260
344
 
261
- "#{key.rjust(max_key_length, ' ')} – expected: #{left}, got: #{right} (#{diff})"
345
+ create_display_string(max_key_length, key, left, right, show_values)
262
346
  end.compact
263
347
  end
264
348
 
349
+ # Show the difference between expected and actual values with one value
350
+ # per line. This is done by hand because as of this writing the author
351
+ # doesn't understand how RSpec does its nice hash diff printing.
352
+ def difference(keys, show_values: false)
353
+ transformed_expected = expected
354
+ if show_values
355
+ transformed_expected = expected.transform_values { |v| v.is_a?(Array) ? v : [v] }
356
+ end
357
+
358
+ loop_through_keys keys, transformed_expected, show_values
359
+ end
360
+
265
361
  def source_lines(keys)
266
362
  line_frequency = @query_stats.query_lines_by_frequency
267
363
  keys_with_source_lines = keys.select { |key| line_frequency[key].present? }
@@ -281,10 +377,33 @@ module ArQueryMatchers
281
377
  "Expected ActiveRecord to not #{crud_operation} any records, got #{@query_stats.query_counts}\n\nWhere unexpected queries came from:\n\n#{source_lines(@query_stats.query_counts.keys).join("\n")}"
282
378
  end
283
379
 
284
- def expectation_failed_message(crud_operation)
380
+ def reject_record(subset, current_expected, key, ignore_missing)
381
+ if subset && !current_expected[key].nil?
382
+ if ignore_missing
383
+ @query_stats.queries[key][:values].empty? || (current_expected[key] - @query_stats.queries[key][:values]).empty?
384
+ else
385
+ (current_expected[key] - @query_stats.queries[key][:values]).empty?
386
+ end
387
+ else
388
+ ignore_missing || @query_stats.queries[key][:values] == current_expected[key]
389
+ end
390
+ end
391
+
392
+ def filter_model_names(subset, show_values, ignore_missing)
285
393
  all_model_names = expected.keys + @query_stats.queries.keys
286
- model_names_with_wrong_count = all_model_names.reject { |key| expected[key] == @query_stats.queries[key][:count] }.uniq
287
- "Expected ActiveRecord to #{crud_operation} #{expected}, got #{@query_stats.query_counts}\nExpectations that differed:\n#{difference(model_names_with_wrong_count).join("\n")}\n\nWhere unexpected queries came from:\n\n#{source_lines(model_names_with_wrong_count).join("\n")}"
394
+ if show_values
395
+ transformed_expected = expected.transform_values { |v| v.is_a?(Array) ? v : [v] }
396
+ all_model_names.reject { |key| reject_record(subset, transformed_expected, key, ignore_missing) }.uniq
397
+ else
398
+ all_model_names.reject { |key| expected[key] == @query_stats.queries[key][:count] }.uniq
399
+ end
400
+ end
401
+
402
+ def expectation_failed_message(crud_operation, show_values: false, subset: false, ignore_missing: false)
403
+ model_names_with_wrong_count = filter_model_names(subset, show_values, ignore_missing)
404
+ message = "Expected ActiveRecord to #{crud_operation} #{expected}, got #{show_values ? @query_stats.query_values : @query_stats.query_counts}\n"
405
+ message += "Expectations that differed:\n#{difference(model_names_with_wrong_count, show_values: show_values).join("\n")}" if show_values
406
+ message + "\n\nWhere unexpected queries came from:\n\n#{source_lines(model_names_with_wrong_count).join("\n")}"
288
407
  end
289
408
  end
290
409
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ar-query-matchers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matan Zruya
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-01-28 00:00:00.000000000 Z
11
+ date: 2025-04-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -163,6 +163,8 @@ files:
163
163
  - README.md
164
164
  - lib/ar_query_matchers.rb
165
165
  - lib/ar_query_matchers/queries/create_counter.rb
166
+ - lib/ar_query_matchers/queries/field_counter.rb
167
+ - lib/ar_query_matchers/queries/field_name.rb
166
168
  - lib/ar_query_matchers/queries/load_counter.rb
167
169
  - lib/ar_query_matchers/queries/model_name.rb
168
170
  - lib/ar_query_matchers/queries/query_counter.rb
@@ -177,7 +179,7 @@ metadata:
177
179
  homepage_uri: https://github.com/Gusto/ar-query-matchers
178
180
  source_code_uri: https://github.com/Gusto/ar-query-matchers
179
181
  changelog_uri: https://github.com/Gusto/ar-query-matchers/blob/master/CHANGELOG.md
180
- post_install_message:
182
+ post_install_message:
181
183
  rdoc_options: []
182
184
  require_paths:
183
185
  - lib
@@ -192,8 +194,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
192
194
  - !ruby/object:Gem::Version
193
195
  version: '0'
194
196
  requirements: []
195
- rubygems_version: 3.0.3.1
196
- signing_key:
197
+ rubygems_version: 3.4.19
198
+ signing_key:
197
199
  specification_version: 4
198
200
  summary: Ruby test matchers for instrumenting ActiveRecord query counts
199
201
  test_files: []