Git Product home page Git Product logo

n_plus_one_control's Introduction

Gem Version Build

N + 1 Control

RSpec and Minitest matchers to prevent the N+1 queries problem.

Example output

Why yet another gem to assert DB queries?

Unlike other libraries (such as db-query-matchers, rspec-sqlimit, etc), with n_plus_one_control you don't have to specify exact expectations to control your code behaviour (e.g. expect { subject }.to query(2).times).

Such expectations are rather hard to maintain, 'cause there is a big chance of adding more queries, not related to the system under test.

NPlusOneControl works differently. It evaluates the code under consideration several times with different scale factors to make sure that the number of DB queries behaves as expected (i.e. O(1) instead of O(N)).

So, it's for performance testing and not feature testing.

Read also "Squash N+1 queries early with n_plus_one_control test matchers for Ruby and Rails".

Why not just use bullet?

Of course, it's possible to use Bullet in tests (see more here), but it's not a silver bullet: there can be both false positives and true negatives.

This gem was born after I've found myself not able to verify with a test yet another N+1 problem.

Sponsored by Evil Martians

Installation

Add this line to your application's Gemfile:

group :test do
  gem "n_plus_one_control"
end

And then execute:

$ bundle

Usage

RSpec

First, add NPlusOneControl to your spec_helper.rb:

# spec_helper.rb
require "n_plus_one_control/rspec"

Then:

# Wrap example into a context with :n_plus_one tag
context "N+1", :n_plus_one do
  # Define `populate` callbacks which is responsible for data
  # generation (and whatever else).
  #
  # It accepts one argument – the scale factor (read below)
  populate { |n| create_list(:post, n) }

  specify do
    expect { get :index }.to perform_constant_number_of_queries
  end
end

NOTE: do not use memoized values within the expectation block!

# BAD – won't work!
subject { get :index }

specify do
  expect { subject }.to perform_constant_number_of_queries
end

# GOOD
specify do
  expect { get :index }.to perform_constant_number_of_queries
end

# BAD — the `page` record would be removed from the database
# but still present in RSpec (due to `let`'s memoization)
let(:page) { create(:page) }

populate { |n| create_list(:comment, n, page: page) }

specify do
  expect { get :show, params: {id: page.id} }.to perform_constant_number_of_queries
end

# GOOD
# Ensure the record is created before `populate`
let!(:page) { create(:page) }

populate { |n| create_list(:comment, n, page: page) }
# ...

Availables modifiers:

# You can specify the RegExp to filter queries.
# By default, it only considers SELECT queries.
expect { get :index }.to perform_constant_number_of_queries.matching(/INSERT/)

# You can also provide custom scale factors
expect { get :index }.to perform_constant_number_of_queries.with_scale_factors(10, 100)

# You can specify the exact number of expected queries
expect { get :index }.to perform_constant_number_of_queries.exactly(1)

Using scale factor in spec

Let's suppose your action accepts parameter, which can make impact on the number of returned records:

get :index, params: {per_page: 10}

Then it is enough to just change per_page parameter between executions and do not recreate records in DB. For this purpose, you can use current_scale method in your example:

context "N+1", :n_plus_one do
  before { create_list :post, 3 }

  specify do
    expect { get :index, params: {per_page: current_scale} }.to perform_constant_number_of_queries
  end
end

Expectations in execution block

Both rspec matchers allows you to put additional expectations inside execution block to ensure that tested piece of code actually does what expected.

context "N+1", :n_plus_one do
  specify do
    expect do
      expect(my_query).to eq(actuall_results)
    end.to perform_constant_number_of_queries
  end
end

Other available matchers

perform_linear_number_of_queries(slope: 1) allows you to test that a query generates linear number of queries with the given slope.

context "when has linear query", :n_plus_one do
  populate { |n| create_list(:post, n) }

  specify do
    expect { Post.find_each { |p| p.user.name } }
      .to perform_linear_number_of_queries(slope: 1)
  end
end

Minitest

First, add NPlusOneControl to your test_helper.rb:

# test_helper.rb
require "n_plus_one_control/minitest"

Then use assert_perform_constant_number_of_queries assertion method:

def test_no_n_plus_one_error
  populate = ->(n) { create_list(:post, n) }

  assert_perform_constant_number_of_queries(populate: populate) do
    get :index
  end
end

You can also use assert_perform_linear_number_of_queries to test for linear queries:

def test_no_n_plus_one_error
  populate = ->(n) { create_list(:post, n) }

  assert_perform_linear_number_of_queries(slope: 1, populate: populate) do
    Post.find_each { |p| p.user.name }
  end
end

You can also specify custom scale factors or filter patterns:

assert_perform_constant_number_of_queries(
  populate: populate,
  scale_factors: [2, 5, 10]
) do
  get :index
end

assert_perform_constant_number_of_queries(
  populate: populate,
  matching: /INSERT/
) do
  do_some_havey_stuff
end

For the constant matcher, you can also specify the expected number of queries as the first argument:

assert_perform_constant_number_of_queries(2, populate: populate) do
  get :index
end

It's possible to specify a filter via NPLUSONE_FILTER env var, e.g.:

NPLUSONE_FILTER = users bundle exec rake test

You can also specify populate as a test class instance method:

def populate(n)
  create_list(:post, n)
end

def test_no_n_plus_one_error
  assert_perform_constant_number_of_queries do
    get :index
  end
end

As in RSpec, you can use current_scale factor instead of populate block:

def test_no_n_plus_one_error
  assert_perform_constant_number_of_queries do
    get :index, params: {per_page: current_scale}
  end
end

With caching

If you use caching you can face the problem when first request performs more DB queries than others. The solution is:

# RSpec

context "N + 1", :n_plus_one do
  populate { |n| create_list :post, n }

  warmup { get :index } # cache something must be cached

  specify do
    expect { get :index }.to perform_constant_number_of_queries
  end
end

# Minitest

def populate(n)
  create_list(:post, n)
end

def warmup
  get :index
end

def test_no_n_plus_one_error
  assert_perform_constant_number_of_queries do
    get :index
  end
end

# or with params

def test_no_n_plus_one
  populate = ->(n) { create_list(:post, n) }
  warmup = -> { get :index }

  assert_perform_constant_number_of_queries population: populate, warmup: warmup do
    get :index
  end
end

If your warmup and testing procs are identical, you can use:

expect { get :index }.to perform_constant_number_of_queries.with_warming_up # RSpec only

Configuration

There are some global configuration parameters (and their corresponding defaults):

# Default scale factors to use.
# We use the smallest possible but representative scale factors by default.
NPlusOneControl.default_scale_factors = [2, 3]

# Print performed queries if true in the case of failure
# You can activate verbosity through env variable NPLUSONE_VERBOSE=1
NPlusOneControl.verbose = false

# Print table hits difference, for example:
#
#   Unmatched query numbers by tables:
#     users (SELECT): 2 != 3
#     events (INSERT): 1 != 2
#
self.show_table_stats = true

# Ignore matching queries
NPlusOneControl.ignore = /^(BEGIN|COMMIT|SAVEPOINT|RELEASE)/

# Ignore queries in cache 
# https://guides.rubyonrails.org/configuring.html#configuring-query-cache
NPlusOneControl.ignore_cached_queries = false

# ActiveSupport notifications event to track queries.
# We track ActiveRecord event by default,
# but can also track rom-rb events ('sql.rom') as well.
NPlusOneControl.event = "sql.active_record"

# configure transactional behaviour for populate method
# in case of use multiple database connections
NPlusOneControl::Executor.tap do |executor|
  connections = ActiveRecord::Base.connection_handler.connection_pool_list.map(&:connection)

  executor.transaction_begin = -> do
    connections.each { |connection| connection.begin_transaction(joinable: false) }
  end
  executor.transaction_rollback = -> do
    connections.each(&:rollback_transaction)
  end
end

# Provide a backtrace cleaner callable object used to filter SQL caller location to display in the verbose mode
# Set it to nil to disable tracing.
#
# In Rails apps, we use Rails.backtrace_cleaner by default.
NPlusOneControl.backtrace_cleaner = ->(locations_array) { do_some_filtering(locations_array) }

# You can also specify the number of backtrace lines to show.
# MOTE: It could be specified via NPLUSONE_BACKTRACE env var
NPlusOneControl.backtrace_length = 1

# Sometime queries could be too large to provide any meaningful insight.
# You can configure an output length limit for quries in verbose mode by setting the following option
# NOTE: It could be specified via NPLUSONE_TRUNCATE env var
NPlusOneControl.truncate_query_size = 100

How does it work?

Take a look at our Executor to figure out what's under the hood.

What's next?

  • More matchers.

It may be useful to provide more matchers/assertions, for example:

# Actually, that means that it is N+1))
assert_linear_number_of_queries { some_code }

# But we can tune it with `coef` and handle such cases as selecting in batches
assert_linear_number_of_queries(coef: 0.1) do
  Post.find_in_batches { some_code }
end

# probably, also make sense to add another curve types
assert_logarithmic_number_of_queries { some_code }
  • Support custom non-SQL events.

N+1 problem is not a database specific: we can have N+1 Redis calls, N+1 HTTP external requests, etc. We can make n_plus_one_control customizable to support these scenarios (technically, we need to make it possible to handle different payload in the event subscriber).

If you want to discuss or implement any of these, feel free to open an issue or propose a pull request.

Development

# install deps
bundle install

# run tests
bundle exec rake

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/n_plus_one_control.

License

The gem is available as open source under the terms of the MIT License.

n_plus_one_control's People

Contributors

akostadinov avatar andrewhampton avatar barthez avatar caalberts avatar dsalahutdinov avatar earendil95 avatar gagalago avatar mrzasa avatar nickskalkin avatar palkan avatar petergoldstein avatar r7kamura avatar rubyconvict avatar wonda-tea-coffee avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

n_plus_one_control's Issues

Using filter NPLUSONE_FILTER in rspec returns always success result

What did you do?

I have a following scenario

context "N+1", :n_plus_one do
      it "n+1" do
        audit_type_2 = create(:audit_type, account: org)
        audit_type_3 = create(:audit_type, account: org)
        audit_type_4 = create(:audit_type, account: another_org)


        expect { do_request(:get, "/api/v3/project_types?org_id=#{org.organization_id}", org_user) }.to perform_constant_number_of_queries
      end
    end

command running test

bundle exec rspec spec/controllers/api/v3/audit_types_controller_spec.rb:338

Failure/Error: expect { do_request(:get, "/api/v3/project_types?org_id=#{org.organization_id}", org_user) }.to perform_constant_number_of_queries
     
       Expected to make the same number of queries, but got:
         194 for N=2
         195 for N=3
       Unmatched query numbers by tables:
     # ./spec/controllers/api/v3/audit_types_controller_spec.rb:345:in `block (4 levels) in <top (required)>'
     # ./spec/spec_helper.rb:83:in

If I try to run test with filter, tests result returns success, but should return failure:

NPLUSONE_VERBOSE=1 NPLUSONE_FILTER=accounts bundle exec rspec spec/controllers/api/v3/audit_types_controller_spec.rb:338

returns: 200 with only specified fields

Finished in 2.42 seconds (files took 6.66 seconds to load)
1 example, 0 failures

What did you expect to happen?

Unmatched query numbers by tables:
accounts (SELECT)

undefined method `include_context'

Hi, having this code:

  describe "#orders" do
    context "n+1", :n_plus_one do
      populate { |n|
        create_list(:order, n)
      }

      specify do
        expect do
          described_class.orders
        end.to(
          perform_constant_number_of_queries.
            with_scale_factors(1, 2)
        )
      end
    end

when I run the spec I get this error:

/Users/me/.rvm/gems/ruby-2.6.2/gems/n_plus_one_control-0.3.1/lib/n_plus_one_control/rspec/context.rb:28:in `block in <top (required)>': undefined method `include_context' for #<RSpec::Core::Configuration:0x00007fd299134b88> (NoMethodError)
Did you mean?  inclusion_filter

I have these rspec gems versions:

    rspec (3.4.0)
      rspec-core (~> 3.4.0)
      rspec-expectations (~> 3.4.0)
      rspec-mocks (~> 3.4.0)
    rspec-activemodel-mocks (1.0.3)
      rspec-mocks (>= 2.99, < 4.0)
    rspec-core (3.4.2)
      rspec-support (~> 3.4.0)
    rspec-expectations (3.4.0)
      rspec-support (~> 3.4.0)
    rspec-mocks (3.4.1)
      rspec-support (~> 3.4.0)
    rspec-rails (3.4.2)
      rspec-core (~> 3.4.0)
      rspec-expectations (~> 3.4.0)
      rspec-mocks (~> 3.4.0)
      rspec-support (~> 3.4.0)
    rspec-support (3.4.1)
    rspec_junit_formatter (0.2.3)
      rspec-core (>= 2, < 4, != 2.12.0

Allow defining `let` in populate

Is your feature request related to a problem? Please describe.

I want to test a show endpoint instead of index, so the request is specific to a record and I define it on a let variable.

Describe the solution you'd like

To allow defining a different let variable each time the populate block is called.

Describe alternatives you've considered

The only way I found is to directly use Record.last when making the request, but it doesn't work if I use it from a let variable. This is not very clean nor DRY.

Strange counting of queries

What did you do?

class Admin::Api::ApplicationPlanLimitsControllerTest < ActionDispatch::IntegrationTest
  include NPlusOneControl::MinitestHelper

  attr_reader :service, :app_plan

  def setup
    @provider = FactoryBot.create(:provider_account)
    @token = FactoryBot.create(:access_token, owner: @provider.admin_users.first!, scopes: %w[account_management]).value
    host! @provider.admin_domain
    @service = FactoryBot.create(:simple_service, account: @provider)
    @app_plan = FactoryBot.create(:simple_application_plan, issuer: service)
  end

  test "something" do
    populate = ->(n) do
      metrics = FactoryBot.create_list(:metric, n, service: service)
      methods = FactoryBot.create_list(:method, n, owner: service)
      metrics.zip(methods).each do |metric, method|
        metric.usage_limits.create(period: :week, value: 1, plan: app_plan)
        method.usage_limits.create(period: :week, value: 1, plan: app_plan)
        method.usage_limits.create(period: :month, value: 1, plan: app_plan)
        metric.usage_limits.create(period: :month, value: 1, plan: app_plan)
      end
    end

    assert_perform_constant_number_of_queries(populate: populate, scale_factors: [2, 10, 2]) do
      get admin_api_application_plan_limits_path(app_plan, format: :json, access_token: @token)
      assert_response :success
    end
  end
end

What did you expect to happen?

PASS

What actually happened?

Minitest::Assertion: Expected to make the same number of queries, but got:
  17 for N=2
  107 for N=10
  35 for N=2
Unmatched query numbers by tables:
  services (SELECT): 2 != 12

Additional context

In the log when running without n_plus_one_control, I don't see such number of queries logged. I suspect it might be an issue with counting the setup queries or something. It makes no sense to me. I will appreciate any advice how to debug.

Environment

Ruby Version:
2.6

Framework Version (Rails, whatever):
Rails 5.1.7

N Plus One Control Version:
n_plus_one_control (0.6.2)

assert_number_of_queries support

I would like to be able to do

test 'my operation performs only a single database query' do
  assert_number_of_queries(1) do
     get :index
  end
end

My particular use case is a scope that had includes which caused 2 additional queries where I didn't need. This is for testing the opposite - unnecessary loading of relations.

Add the possibility to "memoize values within the expectation block"

First of all thank you for this amazing Gem, it's a clean and simple solution to avoid n+1 regressions.

Could you explain why memoizing the action in rspec is not possible? Adding this ability would help a lot for the maintainability of our tests. Typically we always add the controller action in a let(:request) { get :action }, so this gem makes us write an exception.

Add an option to give buffer for existing N+1s

Is your feature request related to a problem? Please describe.

Currently, perform_constant_number_of_queries is very strict on the constant number of queries. In situations where there is an existing N+1 query, but we want to stop it from growing, we cannot use n_plus_one matcher, because it would always fail.

Describe the solution you'd like

Either a chained matcher to create some buffer, or a separate matcher altogether.

Describe alternatives you've considered

A few possible options:

expect { get :index }.to perform_constant_number_of_queries.with_buffer(1)
expect { get :index }.to perform_constant_number_of_queries(buffer: 1)

expect { get :index }.to perform_limited_number_of_queries(buffer: 1)

The idea is that the queries is allowed to increase by buffer for each scale factor.
Assume with buffer(1), at n=1, it has 10 queries, at n=2 it is allowed up to 11 queries, at n=3 it is allowed up to 12 queries, and so on.

Alternative naming:

  • with_buffer
  • with_allowance

Additional context

I'm not sure if the problem I described is the same as tuning with coef mentioned in the README Probably not as this refers to batches:

# But we can tune it with `coef` and handle such cases as selecting in batches
assert_linear_number_of_queries(coef: 0.1) do
  Post.find_in_batches { some_code }
end

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.