Skip to content

Commit

Permalink
Add coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
nepalez committed Feb 12, 2017
1 parent 37e2287 commit ae6e711
Show file tree
Hide file tree
Showing 11 changed files with 188 additions and 90 deletions.
117 changes: 101 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,41 +1,126 @@
# Rspec::Sqlimit
# Test-Driven way of fighting N + 1 queries

Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/rspec/sqlimit`. To experiment with that code, run `bin/console` for an interactive prompt.
RSpec matcher to control number of SQL queries executed by a block of code.

TODO: Delete this and the text above, and describe your gem
It wraps [the answer at Stack Overflow][stack-answer] by [Ryan Bigg][ryan-bigg], which based on Active Support [Notification][notification] and [Instrumentation][instrumentation] mechanisms.

[![Gem Version][gem-badger]][gem]
[![Build Status][travis-badger]][travis]
[![Dependency Status][gemnasium-badger]][gemnasium]
[![Code Climate][codeclimate-badger]][codeclimate]

<a href="https://evilmartians.com/">
<img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>

## Installation

Add this line to your application's Gemfile:
```ruby
# Gemfile
gem "rspec-sqlimit"
```

## Usage

The gem defines matcher `exceed_query_limit` that takes maximum number of SQL requests to be made inside the block.

```ruby
gem 'rspec-sqlimit'
require "rspec-sqlimit"

RSpec.describe "N+1 safety" do
it "doesn't send unnecessary requests to db" do
expect { User.create }.not_to exceed_query_limit(1)
end
end
```

And then execute:
The above specification fails with the following description:

$ bundle
```
Failure/Error: expect { User.create }.not_to exceed_query_limit(1)
Or install it yourself as:
Expected to run maximum 1 queries
The following 3 queries were invoked:
1) begin transaction (0.045 ms)
2) INSERT INTO "users" DEFAULT VALUES (0.19 ms)
3) commit transaction (148.935 ms)
```

$ gem install rspec-sqlimit
You can restrict the matcher using regex:

## Usage
```ruby
require "rspec-sqlimit"

RSpec.describe "N+1 safety" do
it "doesn't send unnecessary requests to db" do
expect { User.create }.not_to exceed_query_limit(1).with(/^INSERT/)
end
end
```

TODO: Write usage instructions here
This time test passes.

## Development
When a specification with a restriction fails, you'll see an error as follows:

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
```ruby
require "rspec-sqlimit"

RSpec.describe "N+1 safety" do
it "doesn't send unnecessary requests to db" do
expect { User.create }.not_to exceed_query_limit(1).with(/^INSERT/)
end
end
```

```
Failure/Error: expect { User.create }.not_to exceed_query_limit(0).with(/INSERT/)
Expected to run maximum 0 queries that match (?-mix:INSERT)
The following 1 queries were invoked among others (see mark ->):
1) begin transaction (0.072 ms)
-> 2) INSERT INTO "users" DEFAULT VALUES (0.368 ms)
3) commit transaction (147.559 ms)
```

To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
## Further Development

## Contributing
For now the gem uses unbinded Active Record queries in error descriptions. For example, when your query contains arguments, the error message will look like

Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/rspec-sqlimit.
```ruby
require "rspec-sqlimit"

RSpec.describe "N+1 safety" do
it "doesn't send unnecessary requests to db" do
expect { User.create(name: "Joe") }.not_to exceed_query_limit(1)
end
end
```

```
Failure/Error: expect { User.create }.not_to exceed_query_limit(0).with(/INSERT/)
Expected to run maximum 0 queries that match (?-mix:INSERT)
The following 1 queries were invoked among others (see mark ->):
1) begin transaction (0.072 ms)
-> 2) INSERT INTO "users" ("name") VALUES (?) (0.368 ms)
3) commit transaction (147.559 ms)
```

This is because [Active Record instrumentation hook][hook] keeps a query and bindings separately (under `:sql` and `:binds` keys). So the challenge is **to bind arguments to the query** in the report to make a debugging a bit simpler.

## License

The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).

[codeclimate-badger]: https://img.shields.io/codeclimate/github/nepalez/rspec-sqlimit.svg?style=flat
[codeclimate]: https://codeclimate.com/github/nepalez/rspec-sqlimit
[gem-badger]: https://img.shields.io/gem/v/rspec-sqlimit.svg?style=flat
[gem]: https://rubygems.org/gems/rspec-sqlimit
[gemnasium-badger]: https://img.shields.io/gemnasium/nepalez/rspec-sqlimit.svg?style=flat
[gemnasium]: https://gemnasium.com/nepalez/rspec-sqlimit
[travis-badger]: https://img.shields.io/travis/nepalez/rspec-sqlimit/master.svg?style=flat
[travis]: https://travis-ci.org/nepalez/rspec-sqlimit
[stack-answer]: http://stackoverflow.com/a/5492207/1869912
[ryan-bigg]: http://ryanbigg.com/
[notification]: http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html
[instrumentation]: http://guides.rubyonrails.org/active_support_instrumentation.html
[hook]: http://guides.rubyonrails.org/active_support_instrumentation.html#sql-active-record
46 changes: 21 additions & 25 deletions lib/rspec/sqlimit.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require "active_support/notifications"
require "active_record"
require "rspec"

Expand All @@ -7,45 +8,40 @@ module SQLimit
require_relative "sqlimit/reporter"
end

Matchers.define :exceed_query_limit do |expected, matcher = nil|
def counter
@counter ||= RSpec::SQLimit::Counter.new
end

def message(expected, matcher, negation = false)
if matcher
condition = negation ? "maximum" : "more than"
restriction = " that match #{matcher}"
suffix = " among others (see mark ->)"
end

reporter = RSpec::SQLimit::Reporter.new(counter, matcher)

<<-MESSAGE.gsub(/ +\|/, "")
|Expected to run #{condition} #{expected} queries#{restriction}
|The following #{reporter.count} queries were invoked#{suffix}:
|#{reporter.lines.join("\n")}
MESSAGE
Matchers.define :exceed_query_limit do |expected|
chain :with do |matcher|
@matcher = matcher
end

match do |block|
counter.count(&block)
RSpec::SQLimit::Reporter.new(counter, matcher).count > expected
@counter ||= RSpec::SQLimit::Counter[@matcher, block]
@counter.count > expected
end

match_when_negated do |block|
counter.count(&block)
RSpec::SQLimit::Reporter.new(counter, matcher).count <= expected
@counter ||= RSpec::SQLimit::Counter[@matcher, block]
@counter.count <= expected
end

failure_message do |_|
message(expected, matcher)
message(expected, @counter)
end

failure_message_when_negated do |_|
message(expected, matcher, true)
message(expected, @counter, true)
end

supports_block_expectations

def message(expected, counter, negation = false)
reporter = RSpec::SQLimit::Reporter.new(counter)
condition = negation ? "maximum" : "more than"
restriction = " that match #{reporter.matcher}" if reporter.matcher

<<-MESSAGE.gsub(/ +\|/, "")
|Expected to run #{condition} #{expected} queries#{restriction}
|#{reporter.call}
MESSAGE
end
end
end
28 changes: 20 additions & 8 deletions lib/rspec/sqlimit/counter.rb
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
module RSpec::SQLimit
class Counter
attr_reader :queries
attr_reader :queries, :matcher

def initialize
@queries = []
def self.[](*args)
new(*args).tap(&:call)
end

# FIXME add mutex to ensure thread-safety
def count
def initialize(matcher, block)
@queries = []
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
yield
@matcher = matcher
@block = block
@mutex = Mutex.new
end

def call
@mutex.synchronize do
@queries = []
ActiveSupport::Notifications.subscribed callback, "sql.active_record" do
@block.call
end
end
end

def count
matcher ? queries.count { |query| query[:sql] =~ matcher } : queries.count
end

private

def callback
@callback ||= lambda(_name, start, finish, _message_id, values) do
@callback ||= lambda do |_name, start, finish, _message_id, values|
return if %w(CACHE SCHEMA).include? values[:name]
queries << { sql: values[:sql], duration: (finish - start) * 1_000 }
end
Expand Down
30 changes: 17 additions & 13 deletions lib/rspec/sqlimit/reporter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,33 @@ module RSpec::SQLimit
class Reporter
attr_reader :matcher

def initialize(counter, matcher = nil)
def initialize(counter)
@counter = counter
@matcher = matcher
@count = counter.count
@queries = counter.queries
@matcher = counter.matcher
end

def count
return queries.count unless @matcher
queries.count { |query| query[:sql] =~ @matcher }
end
def call
suffix = " among others (see mark ->)" if @matcher

def lines
queries.map.with_index { |*args| line(*args) }
return "No queries were invoked" if @queries.empty?

<<-MESSAGE.gsub(/ +\|/, "")
|The following #{@count} queries were invoked#{suffix}:
|#{lines.join("\n")}
MESSAGE
end

private

def line(query, index)
prefix = @matcher && query =~ @matcher ? "->" : " "
"#{prefix} #{index}) #{query[:sql]} (#{query[:duration].round(3)} ms)"
def lines
@queries.map.with_index { |*args| line(*args) }
end

def queries
@queries ||= @counter.queries
def line(query, index)
prefix = (matcher && query[:sql] =~ matcher) ? "->" : " "
"#{prefix} #{index + 1}) #{query[:sql]} (#{query[:duration].round(3)} ms)"
end
end
end
3 changes: 0 additions & 3 deletions spec/dummy/app/models/post.rb

This file was deleted.

4 changes: 1 addition & 3 deletions spec/dummy/db/migrate/20170211104800_create_users.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
class CreateUsers < ActiveRecord::Migration
def change
create_table :users do |t|
t.string :name, index: true
end
create_table :users
end
end
9 changes: 0 additions & 9 deletions spec/dummy/db/migrate/20170211104823_create_posts.rb

This file was deleted.

10 changes: 1 addition & 9 deletions spec/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,9 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 20170211104823) do

create_table "posts", force: :cascade do |t|
t.integer "users_id"
t.string "title"
t.text "text"
end
ActiveRecord::Schema.define(version: 20170211104800) do

create_table "users", force: :cascade do |t|
t.string "name"
t.index ["name"], name: "index_users_on_name"
end

end
Binary file modified spec/dummy/db/test.sqlite3
Binary file not shown.
1 change: 0 additions & 1 deletion spec/dummy/lib/dummy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,4 @@ module Dummy
require_relative "dummy/application"
require_relative "../config/environment"
require_relative "../app/models/user.rb"
require_relative "../app/models/post.rb"
end
30 changes: 27 additions & 3 deletions spec/rspec/sqlimit_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
require "spec_helper"

describe RSpec::SQLimit do
it "does something useful" do
expect(false).to eq(true)
describe "exceed_query_limit" do
context "without restrictions" do
it "works when no queries are made" do
expect { User.new }.not_to exceed_query_limit(0)
end

it "works when actual number of queries is below the limit" do
expect { User.create }.not_to exceed_query_limit(3)
end

it "works when actual number of queries exceeds the limit" do
expect { User.create }.to exceed_query_limit(2)
end
end

context "with a restriction" do
it "works when no queries are made" do
expect { User.new }.not_to exceed_query_limit(0).with(/INSERT/)
end

it "works when actual number of queries is below the limit" do
expect { User.create }.not_to exceed_query_limit(1).with(/INSERT/)
end

it "works when actual number of queries exceeds the limit" do
expect { User.create id: 3 }.to exceed_query_limit(0).with(/INSERT/)
end
end
end

0 comments on commit ae6e711

Please sign in to comment.