ar-query-matchers 0.8.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 +4 -4
- data/README.md +2 -2
- data/lib/ar_query_matchers/queries/field_counter.rb +50 -0
- data/lib/ar_query_matchers/queries/field_name.rb +16 -0
- data/lib/ar_query_matchers/queries/load_counter.rb +2 -2
- data/lib/ar_query_matchers/queries/query_counter.rb +21 -8
- data/lib/ar_query_matchers.rb +131 -12
- metadata +8 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 85641d6041fd4ef3611a70e0f3b5481e9e4d80d23b1ee0a229e5006831367464
|
4
|
+
data.tar.gz: c27f7061b4b22ff584c90a5d4122f2c71b4e5a452f59c432f215fdbf45b01be1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0aa94f77507d3bd00e0ca8b982a665df41ececebc657298241271c2e9d7e15a42e84a387b843537f63711e520b56c5b6c37c1b365e9e73930903269716b7158d
|
7
|
+
data.tar.gz: 3605475400e443fb20782d5f651aed89d359ff244c2d8df82d9b02989d957162aa366d45a3cf44ae9cce6afffc29ee46c10a9e0e9024958bbdf150e2c2c56a78
|
data/README.md
CHANGED
@@ -42,7 +42,7 @@ This gem defines a few categories of matchers:
|
|
42
42
|
Each matcher category includes 3 assertions, for example, for the Load category, you could use the following assertions:
|
43
43
|
- **only_load_models**: Strict assertion of both models loaded and query counts. No other query is allowed.
|
44
44
|
- **only_load_at_most_models**: Strict assertion of models loaded, with an upper bound on the number of queries allowed against each.
|
45
|
-
- **
|
45
|
+
- **not_load_any_models**: No models are allowed to be loaded.
|
46
46
|
- **load_models**: Inclusion. Other models are allowed to be loaded if not specified in the assertion.
|
47
47
|
|
48
48
|
|
@@ -72,7 +72,7 @@ expect { some_code() }.to only_load_at_most_models(
|
|
72
72
|
|
73
73
|
The following spec will pass only if there are no select queries.
|
74
74
|
```ruby
|
75
|
-
expect { some_code() }.to
|
75
|
+
expect { some_code() }.to not_load_any_models
|
76
76
|
```
|
77
77
|
|
78
78
|
The following spec will pass only if there are exactly 4 SQL SELECTs that
|
@@ -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,7 +17,7 @@ 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(?<
|
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` ..."
|
@@ -27,7 +27,7 @@ module ArQueryMatchers
|
|
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
|
@@ -74,6 +82,15 @@ module ArQueryMatchers
|
|
74
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
|
-
|
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
|
data/lib/ar_query_matchers.rb
CHANGED
@@ -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
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
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 =
|
257
|
-
|
339
|
+
left = transformed_expected.fetch(key, show_values ? [] : 0)
|
340
|
+
left = [left] unless left.is_a?(Array) || show_values
|
258
341
|
|
259
|
-
|
342
|
+
right = @query_stats.queries.fetch(key, {})
|
343
|
+
right = show_values ? right.fetch(:values, []) : right.fetch(:count, 0)
|
260
344
|
|
261
|
-
|
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
|
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
|
-
|
287
|
-
|
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.
|
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:
|
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.4.
|
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: []
|