Skip to content

Commit

Permalink
Add execute_queries matcher
Browse files Browse the repository at this point in the history
This matcher is analog to the query assertions available since rails
7.0.
  • Loading branch information
hugopeixoto committed Dec 2, 2024
1 parent 609bb83 commit 80372b7
Showing 3 changed files with 218 additions and 0 deletions.
1 change: 1 addition & 0 deletions lib/rspec/rails/matchers.rb
Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@ module Matchers
require 'rspec/rails/matchers/be_valid'
require 'rspec/rails/matchers/have_http_status'
require 'rspec/rails/matchers/send_email'
require 'rspec/rails/matchers/execute_queries'

if RSpec::Rails::FeatureCheck.has_active_job?
require 'rspec/rails/matchers/active_job'
152 changes: 152 additions & 0 deletions lib/rspec/rails/matchers/execute_queries.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
module RSpec
module Rails
module Matchers
# @api private
#
# Matcher class for `execute_queries` and `execute_no_queries`.
#
# @see RSpec::Rails::Matchers#execute_queries
# @see RSpec::Rails::Matchers#execute_no_queries
class ExecuteQueries < RSpec::Rails::Matchers::BaseMatcher
# @private
def initialize(expected)
@expected = expected
@match = nil
@include_schema = false
end

# @private
def matches?(subject)
counter = SQLCounter.new

@queries = ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do
subject.call
@include_schema ? counter.log_all : counter.log
end

@queries.select! { |q| @match === q } unless @match.nil?
@actual = @queries.count

if @expected.nil?
@actual >= 1
else
@expected == @actual
end
end

# @api public
# @see RSpec::Rails::Matchers::execute_queries
def matching(match)
@match = match
self
end

# @api public
# @see RSpec::Rails::Matchers::execute_queries
def including_schema
@include_schema = true
self
end

# @private
def failure_message
"expected block to #{description}, got #{@actual}"
end

# @private
def failure_message_when_negated
"expected block to not #{description}, got #{@actual}"
end

# @private
def description
message = if @expected.nil?
"execute 1 or more"
else
"execute #{@expected}"
end
message << " SQL #{"query".pluralize(@expected)}"
message << " (including schema operations)" if @include_schema
message << " matching #{@match.inspect}" unless @match.nil?
message
end

# @private
def supports_block_expectations?
true
end

private

def query_word
"query".pluralize(@expected)
end
end

# @api public
# Passes if the number of SQL queries executed by the block is exactly
# `number_of_queries`. If `number_of_queries` is omitted, it passes if it
# executes 1 or more SQL queries.
#
# Use the `matching` method to specify a regular expression to filter the
# queries.
#
# Use the `including_schema` method to include schema related queries.
#
# @example
# expect { Post.first }.to execute_queries(1)
# expect { Post.first }.to execute_queries.matching(/SELECT/)
# expect { Post.columns }.to execute_queries(1).including_schema
def execute_queries(number_of_queries = nil)
ExecuteQueries.new(number_of_queries)
end

# @api public
# Passes if the block executes no SQL queries.
#
# Use the `matching` method to specify a regular expression to filter the
# queries.
#
# Use the `including_schema` method to include schema related queries.
#
# @example
# expect { Post.first }.to execute_no_queries
# expect { Post.first }.to execute_no_queries.matching(/SELECT/)
# expect { Post.columns }.to execute_no_queries.including_schema
def execute_no_queries
execute_queries(0)
end

# Extracted from activerecord/lib/active_record/testing/query_assertions.rb
# @private
class SQLCounter
attr_reader :log_full, :log_all

def initialize
@log_full = []
@log_all = []
end

def log
@log_full.map(&:first)
end

def call(*, payload)
return if payload[:cached]

sql = payload[:sql]
@log_all << sql

unless payload[:name] == "SCHEMA"
bound_values = (payload[:binds] || []).map do |value|
value = value.value_for_database if value.respond_to?(:value_for_database)
value
end

@log_full << [sql, bound_values]
end
end
end
end
end
end
65 changes: 65 additions & 0 deletions spec/rspec/rails/matchers/execute_queries_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
class ExecuteQuery < ActiveRecord::Base
connection.execute <<-SQL
CREATE TABLE execute_queries (
id integer PRIMARY KEY AUTOINCREMENT
)
SQL
end

RSpec.describe "SQL Query matchers" do
context "execute_queries" do
context "without options" do
it "passes" do
expect {
expect { ExecuteQuery.first }.to execute_queries(1)
}.to_not raise_error
end

it "passes for multiple queries" do
expect {
expect { 3.times { ExecuteQuery.first } }.to execute_queries(3)
}.to_not raise_error
end

it "fails" do
expect {
expect { ExecuteQuery.first }.to execute_queries(2)
}.to raise_error("expected block to execute 2 SQL queries, got 1")
end
end

context "including_schema" do
it "passes" do
expect {
expect {
ExecuteQuery.columns
ExecuteQuery.reset_column_information
}.to execute_queries(2).including_schema
}.to_not raise_error
end

it "fails" do
expect {
expect {
ExecuteQuery.columns
ExecuteQuery.reset_column_information
}.to execute_queries(1).including_schema
}.to raise_error("expected block to execute 1 SQL query (including schema operations), got 2")
end
end

context "matching" do
it "passes" do
expect {
expect { ExecuteQuery.first }.to execute_queries(1).matching(/SELECT/)
}.to_not raise_error
end

it "fails" do
expect {
expect { ExecuteQuery.first }.to execute_queries(1).matching(/INSERT/)
}.to raise_error("expected block to execute 1 SQL query matching /INSERT/, got 0")
end
end
end
end

0 comments on commit 80372b7

Please sign in to comment.