Skip to content

Commit 4eb5a1e

Browse files
committed
Add execute_queries matcher
This matcher is analog to the query assertions available since rails 7.0.
1 parent 609bb83 commit 4eb5a1e

File tree

3 files changed

+219
-0
lines changed

3 files changed

+219
-0
lines changed

lib/rspec/rails/matchers.rb

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ module Matchers
2121
require 'rspec/rails/matchers/be_valid'
2222
require 'rspec/rails/matchers/have_http_status'
2323
require 'rspec/rails/matchers/send_email'
24+
require 'rspec/rails/matchers/execute_queries'
2425

2526
if RSpec::Rails::FeatureCheck.has_active_job?
2627
require 'rspec/rails/matchers/active_job'
+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
module RSpec
2+
module Rails
3+
module Matchers
4+
# @api private
5+
#
6+
# Matcher class for `execute_queries` and `execute_no_queries`.
7+
#
8+
# @see RSpec::Rails::Matchers#execute_queries
9+
# @see RSpec::Rails::Matchers#execute_no_queries
10+
class ExecuteQueries < RSpec::Rails::Matchers::BaseMatcher
11+
# @private
12+
def initialize(expected)
13+
@expected = expected
14+
@match = nil
15+
@include_schema = false
16+
end
17+
18+
# @private
19+
def matches?(subject)
20+
counter = SQLCounter.new
21+
22+
@queries = ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do
23+
subject.call
24+
@include_schema ? counter.log_all : counter.log
25+
end
26+
27+
@queries.select! { |q| @match === q } unless @match.nil?
28+
@actual = @queries.count
29+
30+
if @expected.nil?
31+
@actual >= 1
32+
else
33+
@expected == @actual
34+
end
35+
end
36+
37+
# @api public
38+
# @see RSpec::Rails::Matchers::execute_queries
39+
def matching(match)
40+
@match = match
41+
self
42+
end
43+
44+
# @api public
45+
# @see RSpec::Rails::Matchers::execute_queries
46+
def including_schema
47+
@include_schema = true
48+
self
49+
end
50+
51+
# @private
52+
def failure_message
53+
"expected block to #{description}, got #{@actual}"
54+
end
55+
56+
# @private
57+
def failure_message_when_negated
58+
"expected block to not #{description}, got #{@actual}"
59+
end
60+
61+
# @private
62+
def description
63+
message = if @expected.nil?
64+
"execute 1 or more"
65+
else
66+
"execute #{@expected}"
67+
end
68+
message << " SQL #{"query".pluralize(@expected)}"
69+
message << " (including schema operations)" if @include_schema
70+
message << " matching #{@match.inspect}" unless @match.nil?
71+
message
72+
end
73+
74+
# @private
75+
def supports_block_expectations?
76+
true
77+
end
78+
79+
private
80+
81+
def query_word
82+
"query".pluralize(@expected)
83+
end
84+
end
85+
86+
# @api public
87+
# Passes if the number of SQL queries executed by the block is exactly
88+
# `number_of_queries`. If `number_of_queries` is omitted, it passes if it
89+
# executes 1 or more SQL queries.
90+
#
91+
# Use the `matching` method to specify a regular expression to filter the
92+
# queries.
93+
#
94+
# Use the `including_schema` method to include schema related queries.
95+
#
96+
# @example
97+
# expect { Post.first }.to execute_queries(1)
98+
# expect { Post.first }.to execute_queries.matching(/SELECT/)
99+
# expect { Post.columns }.to execute_queries(1).including_schema
100+
def execute_queries(number_of_queries = nil)
101+
ExecuteQueries.new(number_of_queries)
102+
end
103+
104+
# @api public
105+
# Passes if the block executes no SQL queries.
106+
#
107+
# Use the `matching` method to specify a regular expression to filter the
108+
# queries.
109+
#
110+
# Use the `including_schema` method to include schema related queries.
111+
#
112+
# @example
113+
# expect { Post.first }.to execute_no_queries
114+
# expect { Post.first }.to execute_no_queries.matching(/SELECT/)
115+
# expect { Post.columns }.to execute_no_queries.including_schema
116+
def execute_no_queries
117+
execute_queries(0)
118+
end
119+
120+
# Extracted from activerecord/lib/active_record/testing/query_assertions.rb
121+
# @private
122+
class SQLCounter
123+
attr_reader :log_full, :log_all
124+
125+
def initialize
126+
@log_full = []
127+
@log_all = []
128+
end
129+
130+
def log
131+
@log_full.map(&:first)
132+
end
133+
134+
def call(*, payload)
135+
return if payload[:cached]
136+
137+
sql = payload[:sql]
138+
@log_all << sql
139+
140+
unless payload[:name] == "SCHEMA"
141+
bound_values = (payload[:binds] || []).map do |value|
142+
value = value.value_for_database if value.respond_to?(:value_for_database)
143+
value
144+
end
145+
146+
@log_full << [sql, bound_values]
147+
end
148+
end
149+
end
150+
end
151+
end
152+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
class ExecuteQuery < ActiveRecord::Base
2+
connection.execute <<-SQL
3+
CREATE TABLE execute_queries (
4+
id integer PRIMARY KEY AUTOINCREMENT
5+
)
6+
SQL
7+
end
8+
9+
10+
RSpec.describe "SQL Query matchers" do
11+
context "execute_queries" do
12+
context "without options" do
13+
it "passes" do
14+
expect {
15+
expect { ExecuteQuery.first }.to execute_queries(1)
16+
}.to_not raise_error
17+
end
18+
19+
it "passes for multiple queries" do
20+
expect {
21+
expect { 3.times { ExecuteQuery.first } }.to execute_queries(3)
22+
}.to_not raise_error
23+
end
24+
25+
it "fails" do
26+
expect {
27+
expect { ExecuteQuery.first }.to execute_queries(2)
28+
}.to raise_error("expected block to execute 2 SQL queries, got 1")
29+
end
30+
end
31+
32+
context "including_schema" do
33+
it "passes" do
34+
expect {
35+
expect {
36+
ExecuteQuery.columns
37+
ExecuteQuery.reset_column_information
38+
}.to execute_queries(2).including_schema
39+
}.to_not raise_error
40+
end
41+
42+
it "fails" do
43+
expect {
44+
expect {
45+
ExecuteQuery.columns
46+
ExecuteQuery.reset_column_information
47+
}.to execute_queries(1).including_schema
48+
}.to raise_error("expected block to execute 1 SQL query (including schema operations), got 2")
49+
end
50+
end
51+
52+
context "matching" do
53+
it "passes" do
54+
expect {
55+
expect { ExecuteQuery.first }.to execute_queries(1).matching(/SELECT/)
56+
}.to_not raise_error
57+
end
58+
59+
it "fails" do
60+
expect {
61+
expect { ExecuteQuery.first }.to execute_queries(1).matching(/INSERT/)
62+
}.to raise_error("expected block to execute 1 SQL query matching /INSERT/, got 0")
63+
end
64+
end
65+
end
66+
end

0 commit comments

Comments
 (0)