48

我想测试一段代码是否执行尽可能少的 SQL 查询。

ActiveRecord::TestCase似乎有自己的assert_queries方法,可以做到这一点。但由于我没有修补 ActiveRecord,所以它对我来说没什么用。

RSpec 或 ActiveRecord 是否提供任何官方的、公开的方法来计算代码块中执行的 SQL 查询的数量?

4

8 回答 8

55

我认为您通过提及 回答了您自己的问题assert_queries,但这里是:

我建议您查看背后的代码assert_queries并使用它来构建您自己的方法,您可以使用它来计算查询。这里涉及的主要魔法是这一行:

ActiveSupport::Notifications.subscribe('sql.active_record', SQLCounter.new)

今天早上我做了一点小改动,去掉了 ActiveRecord 中用于查询计数的部分,然后得出了这个:

module ActiveRecord
  class QueryCounter
    cattr_accessor :query_count do
      0
    end

    IGNORED_SQL = [/^PRAGMA (?!(table_info))/, /^SELECT currval/, /^SELECT CAST/, /^SELECT @@IDENTITY/, /^SELECT @@ROWCOUNT/, /^SAVEPOINT/, /^ROLLBACK TO SAVEPOINT/, /^RELEASE SAVEPOINT/, /^SHOW max_identifier_length/]

    def call(name, start, finish, message_id, values)
      # FIXME: this seems bad. we should probably have a better way to indicate
      # the query was cached
      unless 'CACHE' == values[:name]
        self.class.query_count += 1 unless IGNORED_SQL.any? { |r| values[:sql] =~ r }
      end
    end
  end
end

ActiveSupport::Notifications.subscribe('sql.active_record', ActiveRecord::QueryCounter.new)

module ActiveRecord
  class Base
    def self.count_queries(&block)
      ActiveRecord::QueryCounter.query_count = 0
      yield
      ActiveRecord::QueryCounter.query_count
    end
  end
end

您将能够在ActiveRecord::Base.count_queries任何地方引用该方法。向它传递一个块,在其中运行您的查询,它将返回已执行的查询数:

ActiveRecord::Base.count_queries do
  Ticket.first
end

为我返回“1”。为了使这项工作:将它放在一个文件中,lib/active_record/query_counter.rb并在你的文件中要求它,config/application.rb如下所示:

require 'active_record/query_counter'

嘿,快!


可能需要一点解释。当我们调用此行时:

    ActiveSupport::Notifications.subscribe('sql.active_record', ActiveRecord::QueryCounter.new)

我们连接到 Rails 3 的小通知框架。它是 Rails 最新主要版本的一个闪亮的小补充,没有人真正知道。subscribe它允许我们使用该方法订阅 Rails 中的事件通知。我们将要订阅的事件作为第一个参数传递,然后将响应的任何对象call作为第二个参数传递。

在这种情况下,当执行查询时,我们的小查询计数器将尽职尽责地增加 ActiveRecord::QueryCounter.query_count 变量,但仅适用于真正的查询。

无论如何,这很有趣。我希望它对你有用。

于 2011-03-30T20:49:22.080 回答
24

我对 Ryan 脚本的看法(稍微清理一下并用匹配器包裹),希望它对某人来说仍然是真实的:

我把它放到 spec/support/query_counter.rb

module ActiveRecord
  class QueryCounter

    attr_reader :query_count

    def initialize
      @query_count = 0
    end

    def to_proc
      lambda(&method(:callback))
    end

    def callback(name, start, finish, message_id, values)
      @query_count += 1 unless %w(CACHE SCHEMA).include?(values[:name])
    end

  end
end

这到 spec/support/matchers/exceed_query_limit.rb

RSpec::Matchers.define :exceed_query_limit do |expected|

  match do |block|
    query_count(&block) > expected
  end

  failure_message_for_should_not do |actual|
    "Expected to run maximum #{expected} queries, got #{@counter.query_count}"
  end

  def query_count(&block)
    @counter = ActiveRecord::QueryCounter.new
    ActiveSupport::Notifications.subscribed(@counter.to_proc, 'sql.active_record', &block)
    @counter.query_count
  end

end

用法:

expect { MyModel.do_the_queries }.to_not exceed_query_limit(2)
于 2012-11-16T20:01:22.933 回答
13

这是 Ryan 和 Yuriy 解决方案的另一种表述,它只是您添加到您的 的一个函数test_helper.rb

def count_queries &block
  count = 0

  counter_f = ->(name, started, finished, unique_id, payload) {
    unless payload[:name].in? %w[ CACHE SCHEMA ]
      count += 1
    end
  }

  ActiveSupport::Notifications.subscribed(counter_f, "sql.active_record", &block)

  count
end

用法只是:

c = count_queries do
  SomeModel.first
end
于 2014-03-13T18:50:03.370 回答
7
  • 有用的错误信息
  • 执行后删除订阅者

(基于 Jaime Cham 的回答)

class ActiveSupport::TestCase
  def sql_queries(&block)
    queries = []
    counter = ->(*, payload) {
      queries << payload.fetch(:sql) unless ["CACHE", "SCHEMA"].include?(payload.fetch(:name))
    }

    ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &block)

    queries
  end

  def assert_sql_queries(expected, &block)
    queries = sql_queries(&block)
    queries.count.must_equal(
      expected,
      "Expected #{expected} queries, but found #{queries.count}:\n#{queries.join("\n")}"
    )
  end
end
于 2017-05-05T16:46:43.673 回答
1

根据 Jaime 的回答,以下内容支持对当前测试用例中到目前为止的查询数量的断言,并在失败时记录语句。我认为将这样的 SQL 检查与功能测试结合起来在实用上很有用,因为它减少了设置工作量。

class ActiveSupport::TestCase

   ActiveSupport::Notifications.subscribe('sql.active_record') do |name, started, finished, unique_id, payload|
     (@@queries||=[]) << payload unless payload[:name].in? %w(CACHE SCHEMA)
   end

   def assert_queries_count(expected_count, message=nil)
     assert_equal expected_count, @@queries.size,
       message||"Expected #{expected_count} queries, but #{@@queries.size} queries occurred.#{@@queries[0,20].join(' ')}"
   end

   # common setup in a super-class (or use Minitest::Spec etc to do it another way)
   def setup
     @@queries = []
   end

end

用法:

def test_something
   post = Post.new('foo')
   assert_queries_count 1 # SQL performance check
   assert_equal "Under construction", post.body # standard functional check
end

请注意,查询断言应该立即发生,以防其他断言本身触发额外的查询。

于 2015-04-30T12:53:01.970 回答
1

这是一个可以轻松计算与给定模式匹配的查询的版本。

module QueryCounter

  def self.count_selects(&block)
    count(pattern: /^(\s+)?SELECT/, &block)
  end

  def self.count(pattern: /(.*?)/, &block)
    counter = 0

    callback = ->(name, started, finished, callback_id, payload) {
      counter += 1 if payload[:sql].match(pattern)
      # puts "match? #{!!payload[:sql].match(pattern)}: #{payload[:sql]}"
    }

    # http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html
    ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)

    counter
  end

end

用法:

test "something" do
  query_count = count_selects {
    Thing.first
    Thing.create!(size: "huge")
  }
  assert_equal 1, query_count
end
于 2015-10-14T15:27:18.973 回答
1

我最终创建了一个小宝石来抽象这个问题:sql_spy

只需将其添加到您的 Gemfile 中:

gem "sql_spy"

将您的代码包装在里面SqlSpy.track { ... }

queries = SqlSpy.track do
  # Some code that triggers ActiveRecord queries
  users = User.all
  posts = BlogPost.all
end

...并在断言中使用块的返回值:

expect(queries.size).to eq(2)
expect(queries[0].sql).to eq("SELECT * FROM users;")
expect(queries[0].model_name).to eq("User")
expect(queries[0].select?).to be_true
expect(queries[0].duration).to eq(1.5)
于 2020-10-11T17:35:44.397 回答
0

我添加了根据 Yuriy 的解决方案检查每个表的查询的功能

# spec/support/query_counter.rb
require 'support/matchers/query_limit'

module ActiveRecord
  class QueryCounter
    attr_reader :queries

    def initialize
      @queries = Hash.new 0
    end

    def to_proc
      lambda(&method(:callback))
    end

    def callback(name, start, finish, message_id, values)
      sql = values[:sql]

      if sql.include? 'SAVEPOINT'
        table = :savepoints
      else
        finder = /select.+"(.+)"\..+from/i if sql.include? 'SELECT'
        finder = /insert.+"(.+)".\(/i if sql.include? 'INSERT'
        finder = /update.+"(.+)".+set/i if sql.include? 'UPDATE'
        finder = /delete.+"(.+)" where/i if sql.include? 'DELETE'
        table = sql.match(finder)&.send(:[],1)&.to_sym
      end

      @queries[table] += 1 unless %w(CACHE SCHEMA).include?(values[:name])

      return @queries
    end

    def query_count(table = nil)
      if table
        @queries[table]
      else
        @queries.values.sum
      end
    end
  end
end

RSpec 匹配器看起来像

# spec/support/matchers/query_limit.rb
RSpec::Matchers.define :exceed_query_limit do |expected, table|
  supports_block_expectations

  match do |block|
    query_count(table, &block) > expected
  end

  def query_count(table, &block)
    @counter = ActiveRecord::QueryCounter.new
    ActiveSupport::Notifications.subscribed(@counter.to_proc, 'sql.active_record', &block)
    @counter.query_count table
  end

  failure_message_when_negated do |actual|
    queries = 'query'.pluralize expected
    table_name = table.to_s.singularize.humanize.downcase if table

    out = "expected to run a maximum of #{expected}"
    out += " #{table_name}" if table
    out += " #{queries}, but got #{@counter.query_count table}"
  end
end

RSpec::Matchers.define :meet_query_limit do |expected, table|
  supports_block_expectations

  match do |block|
    if expected.is_a? Hash
      results = queries_count(table, &block)
      expected.all? { |table, count| results[table] == count }
    else
      query_count(&block) == expected
    end
  end

  def queries_count(table, &block)
    @counter = ActiveRecord::QueryCounter.new
    ActiveSupport::Notifications.subscribed(@counter.to_proc, 'sql.active_record', &block)
    @counter.queries
  end

  def query_count(&block)
    @counter = ActiveRecord::QueryCounter.new
    ActiveSupport::Notifications.subscribed(@counter.to_proc, 'sql.active_record', &block)
    @counter.query_count
  end

  def message(expected, table, negated = false)
    queries = 'query'.pluralize expected
    if expected.is_a? Hash
      results = @counter.queries
      table, expected = expected.find { |table, count| results[table] != count }
    end

    table_name = table.to_s.singularize.humanize.downcase if table

    out = 'expected to'
    out += ' not' if negated
    out += " run exactly #{expected}"
    out += " #{table_name}" if table
    out += " #{queries}, but got #{@counter.query_count table}"
  end

  failure_message do |actual|
    message expected, table
  end

  failure_message_when_negated do |actual|
    message expected, table, true
  end
end

用法

expect { MyModel.do_the_queries }.to_not meet_query_limit(3)
expect { MyModel.do_the_queries }.to meet_query_limit(3)
expect { MyModel.do_the_queries }.to meet_query_limit(my_models: 2, other_tables: 1)
于 2019-08-27T21:18:22.030 回答