• Stars
    star
    556
  • Rank 80,098 (Top 2 %)
  • Language
    Ruby
  • License
    MIT License
  • Created over 7 years ago
  • Updated 3 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

RSpec and Minitest matchers to prevent N+1 queries problem

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)/

# 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.

More Repositories

1

logidze

Database changes log for Rails
Ruby
1,596
star
2

action_policy

Authorization framework for Ruby/Rails applications
Ruby
1,404
star
3

isolator

Detect non-atomic interactions within DB transactions
Ruby
850
star
4

anyway_config

Configuration library for Ruby gems and applications
Ruby
735
star
5

active_delivery

Ruby framework for keeping all types of notifications (mailers, push notifications, whatever) in one place
Ruby
595
star
6

store_attribute

ActiveRecord extension which adds typecasting to store accessors
Ruby
376
star
7

view_component-contrib

A collection of extension and developer tools for ViewComponent
Ruby
345
star
8

litecable

Lightweight Action Cable implementation (Rails-free)
Ruby
298
star
9

acli

Action Cable command-line client
Ruby
230
star
10

action-cable-testing

Action Cable testing utils
Ruby
212
star
11

rubanok

Parameters-based transformation DSL
Ruby
205
star
12

active_event_store

Rails Event Store in a more Rails way
Ruby
180
star
13

action_policy-graphql

Action Policy integration for GraphQL
Ruby
151
star
14

engems

Rails component-based architecture on top of engines and gems (showroom)
Ruby
136
star
15

influxer

InfluxDB ActiveRecord-style
Ruby
118
star
16

abstract_notifier

ActionMailer-like interface for any type of notifications
Ruby
116
star
17

turbo-music-drive

Exploring Turbo future features while building a music library app
Ruby
102
star
18

wsdirector

All the world's a server, and all the men and women merely clients
Ruby
102
star
19

gem-check

GemCheck: Writing Better Ruby Gems Checklist
CSS
95
star
20

pgrel

ActiveRecord extension for querying hstore and jsonb
Ruby
93
star
21

rbytes

Ruby Bytes helps you build, deploy and install Ruby and Rails application templates
Ruby
68
star
22

faqueue

Researching background jobs fairness
Ruby
66
star
23

downstream

Straightforward way to implement communication between Rails Engines using the Publish-Subscribe pattern.
Ruby
51
star
24

newgem

Custom script to generate new gems
Ruby
35
star
25

turbo-view-transitions

View Transitions API for Turbo
TypeScript
33
star
26

influx_udp

Erlang InfluxDB UDP writer
Erlang
31
star
27

ruby-dip

Docker-based development environment for hacking Ruby MRI
Dockerfile
30
star
28

erlgrpc

GRPC client for Erlang
Erlang
25
star
29

as3_p2plocal

as3 lib for local p2p connections (serverless rtmfp)
ActionScript
25
star
30

rails-intest-views

Generate view templates dynamically in Rails tests
Ruby
21
star
31

docsify-namespaced

Docsify plugin to work with namespaces
JavaScript
12
star
32

sharelatex-vagrant-ansible

Vagrant + Ansible configuration for ShareLatex
Shell
12
star
33

slog-spy

Slog handler to temporary deliver formatted verbose logs to an arbitrary target
Go
12
star
34

docs-example

Playground for dealing with documentation engines
7
star
35

ruby-russia-2020

RubyRussia 2020 "Frontendless Rails" workshop demo app
Ruby
6
star
36

engine-cable-app

Experimenting with Action Cable and engines
Ruby
6
star
37

rails-on-wasm-playground

A minimal Rails app to showcase Rails on Wasm features
Ruby
5
star
38

palkan

It's me
4
star
39

ruby-compatibility-examples

Collections of reproduction cases for TruffleRuby vs. MRI (in)compatibility
Ruby
3
star
40

erffmpeg

Erlang wrapper for some ffmpeg
C
3
star
41

th-dummy

TH Dummy
Ruby
2
star
42

ulitos

Erlang utils modules
Erlang
2
star
43

meetings

Good old Teachbase Meetings client
ActionScript
2
star
44

rebar_templates

Custom rebar templates
Erlang
1
star
45

macos-setup

Shell
1
star
46

bitrix-orm

Bitrix kinda ORM for IBlockElements and CUser.
PHP
1
star
47

anycable-elements

Web Components for AnyCable console
JavaScript
1
star
48

adventofcode2018

https://adventofcode.com
Rust
1
star
49

tb_utils

ActionScript 3 library
ActionScript
1
star